mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-25 11:44:16 +00:00
Webhook delivery was neither idempotent nor order-safe, and every failure
returned 400, which Stripe does not retry. A transient DB or hold error
therefore dropped a subscription change silently and permanently.
- New stripe_processed_events table: event_id as primary key dedups
redelivery, and event_created per customer drops stale out-of-order
deliveries.
- HandleWebhook distinguishes ErrWebhookSignature (400, no retry) from
ErrWebhookProcessing (500, Stripe redelivers). The event handlers
return errors instead of swallowing them. ErrBillingDisabled maps to
400: the route is mounted but billing is off, so redelivery can never
succeed and Stripe should stop rather than retry to exhaustion.
- Refuse to boot when billing is enabled with an empty
STRIPE_WEBHOOK_SECRET. Stripe HMACs with the empty key, so an
attacker can reproduce the signature and the endpoint is forgeable.
- UpdateCrewTierOnAllHolds retries each hold (3 attempts, linear
backoff, 5s per request) and returns a joined error so the webhook
can fail and let Stripe redeliver.
The fan-out contacts holds concurrently rather than in sequence. Serially,
one unreachable hold burns the caller's entire 10s budget on its own
retries (3 x 5s plus backoff) and the holds after it are never contacted;
because Stripe redelivers in the same order, a persistently-down first
hold means the rest are never updated at all.
On the hold, the signature-validated sub claim is now the source of truth
for updateCrewTier: a mismatched body userDid is rejected with 403 rather
than retargeting the grant to another DID. "Not crew on this hold" is a
200 no-op, since the appview fans updates out to every managed hold and a
subscriber is not crew everywhere.
That no-op has to be told apart from a storage failure. GetCrewMember
collapsed both into one generic error, so a CAR-store failure read as
"not a member", answered 200, and let the appview record the event as
processed — losing the tier grant permanently, which is exactly the
failure mode this commit exists to prevent. Missing records now carry an
ErrCrewMemberNotFound sentinel, and anything else returns 500 so Stripe
redelivers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
75 lines
2.4 KiB
Go
75 lines
2.4 KiB
Go
//go:build billing
|
|
|
|
package billing
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// RegisterRoutes registers billing HTTP routes on the router.
|
|
// These routes handle subscription management and Stripe webhooks.
|
|
func (m *Manager) RegisterRoutes(r chi.Router) {
|
|
if !m.Enabled() {
|
|
slog.Info("Billing routes disabled (not configured)")
|
|
return
|
|
}
|
|
|
|
slog.Info("Registering billing routes")
|
|
|
|
// Stripe webhook (public, verified by Stripe signature)
|
|
r.Post("/api/stripe/webhook", m.handleStripeWebhook)
|
|
}
|
|
|
|
// handleStripeWebhook processes incoming Stripe webhook events.
|
|
//
|
|
// Status codes matter for Stripe's retry behavior: a bad signature is a client
|
|
// error (400, no retry), while a transient processing failure returns 500 so
|
|
// Stripe redelivers. Event idempotency makes redelivery safe.
|
|
func (m *Manager) handleStripeWebhook(w http.ResponseWriter, r *http.Request) {
|
|
if err := m.HandleWebhook(r); err != nil {
|
|
slog.Error("Stripe webhook error", "error", err)
|
|
switch {
|
|
case errors.Is(err, ErrWebhookSignature):
|
|
http.Error(w, "invalid signature", http.StatusBadRequest)
|
|
case errors.Is(err, ErrBillingDisabled):
|
|
// Permanent: this deployment has the route mounted but billing off,
|
|
// so no amount of redelivery will make it processable. 400 tells
|
|
// Stripe to stop rather than retry the event to exhaustion.
|
|
http.Error(w, "billing not enabled", http.StatusBadRequest)
|
|
default:
|
|
// ErrWebhookProcessing and anything unclassified: assume transient
|
|
// and let Stripe retry. Event idempotency makes redelivery safe.
|
|
http.Error(w, "processing error", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
if _, err := w.Write([]byte(`{"received": true}`)); err != nil {
|
|
slog.Error("Failed to write webhook response", "error", err)
|
|
}
|
|
}
|
|
|
|
// HandleGetSubscription is an HTTP handler that returns subscription info as JSON.
|
|
// Used by the settings page HTMX endpoint.
|
|
func (m *Manager) HandleGetSubscription(w http.ResponseWriter, r *http.Request, userDID string) {
|
|
info, err := m.GetSubscriptionInfo(userDID)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusOK)
|
|
if _, writeErr := w.Write([]byte("")); writeErr != nil {
|
|
slog.Error("Failed to write empty response", "error", writeErr)
|
|
}
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(info); err != nil {
|
|
slog.Error("Failed to encode subscription info", "error", err)
|
|
}
|
|
}
|