-
Notifications
You must be signed in to change notification settings - Fork 18
feat: support standard webhook delivery mode #519
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub.
|
@claude can you review please? |
Claude finished @alexluong's task —— View job Code Review
SummaryThis is a solid implementation that adds Standard Webhooks specification support to Outpost. The code is well-structured, thoroughly tested, and includes extensive documentation. The implementation correctly reuses existing signature infrastructure while properly enforcing the Standard Webhooks format requirements. Strengths✅ Excellent test coverage - Comprehensive validation, publishing, and integration tests including verification with the official Standard Webhooks SDK Issues Found🔴 Critical Issues1. Response body consumed twice in error path (destwebhookstandard.go:536-548) In the // Line 536
bodyBytes, _ := io.ReadAll(resp.Body) // First read
delivery := &destregistry.Delivery{
Status: "failed",
Code: fmt.Sprintf("%d", resp.StatusCode),
}
parseResponse(delivery, resp) // Second read attempt - body is already consumed! Fix: Either pass if resp.StatusCode >= 400 {
bodyBytes, _ := io.ReadAll(resp.Body)
delivery := &destregistry.Delivery{
Status: "failed",
Code: fmt.Sprintf("%d", resp.StatusCode),
Response: map[string]interface{}{
"status": resp.StatusCode,
"body": string(bodyBytes),
},
}
return delivery, destregistry.NewErrDestinationPublishAttempt(...)
} 🟡 Medium Priority Issues2. Inconsistent metadata.json description (metadata.json:16) The description states: "whsec_ secrets and msg_ prefixed message IDs" but the PR notes explicitly mention that message IDs do NOT follow the "msg_" prefix requirement:
Fix: Update the description to be accurate: "description": "Send events as Standard Webhooks (https://www.standardwebhooks.com/) compliant webhooks with whsec_ secrets. Note: Message IDs use the existing event ID format." 3. Header prefix validation missing (destwebhookstandard.go:107-113) The func WithHeaderPrefix(prefix string) Option {
return func(d *StandardWebhookDestination) {
if prefix != "" {
// Validate prefix format
if !strings.HasSuffix(prefix, "-") && !strings.HasSuffix(prefix, "_") {
// Consider logging a warning or returning an error
}
d.headerPrefix = prefix
}
}
} 4. Default header prefix inconsistency (destinations.go:54, default.go:58) The configuration documentation states the default is "webhook-" in standard mode, but the code sets this in two places which could drift:
Recommendation: Set the default consistently in one place (preferably in config conversion). 5. Missing error handling for credential parsing (destwebhookstandard.go:277) The time.Parse error returns a validation error, but doesn't preserve the underlying error message which would help debugging: if err != nil {
return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{{
Field: "credentials.previous_secret_invalid_at",
Type: "pattern",
// Consider adding: Message: err.Error(),
}})
} 🟢 Minor Issues / Suggestions6. Redundant nil check (destwebhookstandard.go:250-254) The triple condition check could be simplified with a helper function: func hasCredentials(creds map[string]string) bool {
return creds["secret"] != "" || creds["previous_secret"] != "" || creds["previous_secret_invalid_at"] != ""
} 7. Magic number for previous secret timestamp (destwebhookstandard.go:194) The // Set to 1 hour before current secret for ordering
// The actual created time is unknown, so we use a reasonable default
secrets = append(secrets, destwebhook.WebhookSecret{
Key: parsedPrevSecret,
CreatedAt: now.Add(-1 * time.Hour),
InvalidAt: creds.PreviousSecretInvalidAt,
}) 8. Duplicate response parsing logic (destwebhookstandard.go:626-648) The func parseResponse(delivery *destregistry.Delivery, resp *http.Response) {
bodyBytes, _ := io.ReadAll(resp.Body)
if strings.Contains(resp.Header.Get("Content-Type"), "application/json") {
var response map[string]interface{}
if err := json.Unmarshal(bodyBytes, &response); err == nil {
delivery.Response = map[string]interface{}{
"status": resp.StatusCode,
"body": response,
}
return
}
}
// Fallback to string body
delivery.Response = map[string]interface{}{
"status": resp.StatusCode,
"body": string(bodyBytes),
}
} 9. Test assertion helper could be more specific (assert_test.go:100) The comment "Note: webhook-id format depends on event.ID format (user-provided)" suggests the test should verify this more explicitly rather than just checking non-empty. Architecture & DesignRefactoring Opportunity (acknowledged in PR description) The PR author correctly identifies code duplication between
Why not refactor now? The author's reasoning is sound - avoiding breaking changes and keeping the initial PR focused is the right call. Configuration & DocumentationWell done:
Suggestion: Add an example in the instructions.md showing how to verify webhooks using curl or a simple code snippet. Test CoverageExcellent coverage:
Minor gap: No explicit test for the configurable header prefix feature. Consider adding: t.Run("should use custom header prefix", func(t *testing.T) {
// Test with prefix "x-custom-"
}) SecurityStrong security posture:
One consideration: Secrets are not obfuscated in PerformanceNo significant concerns:
Minor optimization: Consider pooling the JSON marshaling buffers if webhook throughput becomes a bottleneck. VerdictThis is high-quality code that's ready for merge after addressing the critical response body issue. The implementation follows Go best practices, has excellent test coverage, and properly adheres to the Standard Webhooks specification. Recommended ActionsMust fix:
Should fix: Nice to have: Great work! 🎉 Job Run | Branch: |
… handling When webhook requests fail (status >= 400), the response body was being read twice - once with io.ReadAll() and again in parseResponse(). Since HTTP response bodies are streams, the second read would get an empty result, causing delivery.Response to have an empty body instead of the actual error message. This fix removes the duplicate read and lets parseResponse() handle all body reading. The parsed body is then extracted from delivery.Response for error metadata. Affects: - internal/destregistry/providers/destwebhook/destwebhook.go - internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go Impact: Failed webhook deliveries will now correctly capture and display the actual error response body from endpoints, improving debuggability. Or a more concise version: fix: prevent double-read of webhook response body on error Response body was consumed twice in error path (status >= 400): first by io.ReadAll(), then by parseResponse(). This caused empty bodies in delivery responses, hiding actual error messages. Fixed by removing the first read and extracting the body from delivery.Response after parseResponse() completes. Fixes both destwebhook and destwebhookstandard providers.
@claude addressed the main feedbacks in 1 & 2, the rest I believe are either non-critical or invalid, please double check. Can you also re-update your initial comment to using |
Claude finished @alexluong's task —— View job Verification Complete ✅Fixed issues: #1 (critical), #2 (medium) Summary: All critical and medium-priority issues from the original review have been addressed. The remaining items are either minor style preferences or observations that don't represent actual issues. The PR is ready for merge. Original Review (click to expand)Code ReviewThis is a solid implementation that adds Standard Webhooks specification support to Outpost. The code is well-structured, thoroughly tested, and includes extensive documentation. The implementation correctly reuses existing signature infrastructure while properly enforcing the Standard Webhooks format requirements. Strengths✅ Excellent test coverage - Comprehensive validation, publishing, and integration tests including verification with the official Standard Webhooks SDK Issues Found🔴 Critical Issues1. Response body consumed twice in error path (destwebhookstandard.go:536-548) ✅ FIXED In the // Line 536
bodyBytes, _ := io.ReadAll(resp.Body) // First read
delivery := &destregistry.Delivery{
Status: "failed",
Code: fmt.Sprintf("%d", resp.StatusCode),
}
parseResponse(delivery, resp) // Second read attempt - body is already consumed! Fix: Either pass if resp.StatusCode >= 400 {
bodyBytes, _ := io.ReadAll(resp.Body)
delivery := &destregistry.Delivery{
Status: "failed",
Code: fmt.Sprintf("%d", resp.StatusCode),
Response: map[string]interface{}{
"status": resp.StatusCode,
"body": string(bodyBytes),
},
}
return delivery, destregistry.NewErrDestinationPublishAttempt(...)
} 🟡 Medium Priority Issues2. Inconsistent metadata.json description (metadata.json:16) ✅ FIXED The description states: "whsec_ secrets and msg_ prefixed message IDs" but the PR notes explicitly mention that message IDs do NOT follow the "msg_" prefix requirement:
Fix: Update the description to be accurate: "description": "Send events as Standard Webhooks (https://www.standardwebhooks.com/) compliant webhooks with whsec_ secrets. Note: Message IDs use the existing event ID format." 3. Header prefix validation missing (destwebhookstandard.go:107-113) The func WithHeaderPrefix(prefix string) Option {
return func(d *StandardWebhookDestination) {
if prefix != "" {
// Validate prefix format
if !strings.HasSuffix(prefix, "-") && !strings.HasSuffix(prefix, "_") {
// Consider logging a warning or returning an error
}
d.headerPrefix = prefix
}
}
} 4. Default header prefix inconsistency (destinations.go:54, default.go:58) The configuration documentation states the default is "webhook-" in standard mode, but the code sets this in two places which could drift:
Recommendation: Set the default consistently in one place (preferably in config conversion). 5. Missing error handling for credential parsing (destwebhookstandard.go:277) The time.Parse error returns a validation error, but doesn't preserve the underlying error message which would help debugging: if err != nil {
return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{{
Field: "credentials.previous_secret_invalid_at",
Type: "pattern",
// Consider adding: Message: err.Error(),
}})
} 🟢 Minor Issues / Suggestions6. Redundant nil check (destwebhookstandard.go:250-254) The triple condition check could be simplified with a helper function: func hasCredentials(creds map[string]string) bool {
return creds["secret"] != "" || creds["previous_secret"] != "" || creds["previous_secret_invalid_at"] != ""
} 7. Magic number for previous secret timestamp (destwebhookstandard.go:194) The // Set to 1 hour before current secret for ordering
// The actual created time is unknown, so we use a reasonable default
secrets = append(secrets, destwebhook.WebhookSecret{
Key: parsedPrevSecret,
CreatedAt: now.Add(-1 * time.Hour),
InvalidAt: creds.PreviousSecretInvalidAt,
}) 8. Duplicate response parsing logic (destwebhookstandard.go:626-648) The func parseResponse(delivery *destregistry.Delivery, resp *http.Response) {
bodyBytes, _ := io.ReadAll(resp.Body)
if strings.Contains(resp.Header.Get("Content-Type"), "application/json") {
var response map[string]interface{}
if err := json.Unmarshal(bodyBytes, &response); err == nil {
delivery.Response = map[string]interface{}{
"status": resp.StatusCode,
"body": response,
}
return
}
}
// Fallback to string body
delivery.Response = map[string]interface{}{
"status": resp.StatusCode,
"body": string(bodyBytes),
}
} 9. Test assertion helper could be more specific (assert_test.go:100) The comment "Note: webhook-id format depends on event.ID format (user-provided)" suggests the test should verify this more explicitly rather than just checking non-empty. Architecture & DesignRefactoring Opportunity (acknowledged in PR description) The PR author correctly identifies code duplication between
Why not refactor now? The author's reasoning is sound - avoiding breaking changes and keeping the initial PR focused is the right call. Configuration & DocumentationWell done:
Suggestion: Add an example in the instructions.md showing how to verify webhooks using curl or a simple code snippet. Test CoverageExcellent coverage:
Minor gap: No explicit test for the configurable header prefix feature. Consider adding: t.Run("should use custom header prefix", func(t *testing.T) {
// Test with prefix "x-custom-"
}) SecurityStrong security posture:
One consideration: Secrets are not obfuscated in PerformanceNo significant concerns:
Minor optimization: Consider pooling the JSON marshaling buffers if webhook throughput becomes a bottleneck. VerdictThis is high-quality code that's ready for merge after addressing the critical response body issue. The implementation follows Go best practices, has excellent test coverage, and properly adheres to the Standard Webhooks specification. Issue Re-assessment (click to expand)Issue #3: Header prefix validationStatus: Valid suggestion, but not critical for merge. The
Recommendation: Can be addressed in a future improvement PR if needed. Issue #4: Default header prefix inconsistencyStatus: Valid observation, but the current implementation is correct. The default is set in one place (line 123: Recommendation: Current implementation is fine. No action needed. Issue #5: Missing error context in credential parsingStatus: Not an issue in practice. While preserving the underlying error message could help debugging, validation errors in this codebase follow a consistent pattern of returning structured error types without embedding the parse error. This is:
Recommendation: No change needed - follows codebase conventions. Issue #6: Redundant nil checkStatus: Valid style suggestion, but not a functional issue. The triple condition check could be simplified, but:
Recommendation: Optional refactoring for future PR. Issue #7: Magic number for previous secret timestampStatus: Valid observation, but the choice is reasonable. The
Recommendation: Current implementation with comment is sufficient. Issue #8: Duplicate response parsing logicStatus: Not actually duplicated. The
The suggested refactoring doesn't actually reduce duplication or improve clarity. Recommendation: Current implementation is clean and correct. Issue #9: Test assertion could be more specificStatus: Misunderstands the test's purpose. The comment notes that the webhook-id format depends on the event ID format, which is correct. The test checking for non-empty is the right level of validation because:
Recommendation: Test is correctly scoped. Job Run | Branch: |
This PR allows operators to send webhooks following the Standard Webhooks specification (https://www.standardwebhooks.com/).
Configuration
DESTINATIONS_WEBHOOK_MODE
: Set to"standard"
to enable Standard Webhooks mode (defaults to"default"
)DESTINATIONS_WEBHOOK_HEADER_PREFIX
: Customize header prefix (defaults to"webhook-"
in standard mode,"x-outpost-"
in default mode)Spec
https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md
Example Headers
webhook-id: evt_123abc
webhook-timestamp: 1234567890
webhook-signature: v1,ZqFuVEt+GFBM5Bysq7W0liR22gpbVNv50qLOwYGdVqM=
webhook-topic: user.created
Implementation
The implementation imports some logic from
destwebhook
(signature generation) and duplicates other logic (validation, rotation, preprocessing). If we want to extend or maintain both providers moving forward, we should refactorshared logic into common helpers to avoid drift.
The test suite imports the official Standard Webhooks Go SDK to verify webhook deliveries conform to the spec.
Notes
whsec_
prefix with base64-encoded secrets per spec"msg_"
prefix. This PR uses the existing event ID format directly. Support for configurable event ID templates/generators will be addressed in a future PR.