DC-057: close checkout-to-license contract drift (grade B)
Canonical product catalog at src/billing/catalog.js shared by Stripe Checkout client (src/billing/stripe-client.js), webhook bridge (scripts/stripe-license-bridge.js), and pricing page (status/pricing/index.html). One-time payment keyed by productId at $20/$50/$70/$99 — no more monthly/annual subscription drift. Bridge resolves duration via metadata.productId (single contract), requires payment_status === 'paid' before fulfillment (rejects unpaid/no_payment_required/missing with ack 200), handles async_payment_succeeded for ACH/SEPA delayed-payment flow. License persisted to fulfillment-store BEFORE email — SMTP failure path serves the persisted code via the new /api/v1/billing/lookup/:sessionId endpoint (the documented customer recovery path). Layer-1 (event-id) + layer-2 (session-id) idempotency prevent duplicate issuance. Checkout return URLs derived from STRIPE_PUBLIC_ORIGIN or STRIPE_ALLOWED_HOSTS (not raw Host header) — closes host-header-poisoning + session-ID-leak attack class. 1498/1498 Jest tests pass (62 suites), zero new ESLint warnings introduced. Test files: - stripe-license-bridge.test.js (24 tests) - billing-lookup.test.js (8 tests, HTTP-level) - bridge-lookup-http.test.js (5 tests, uses exported createServer) - pricing-page-catalog.test.js (9 tests, per-tier consistency) - checkout-origin.test.js (6 tests, host injection rejection) - stripe-client.test.js (rewrite for productId + mode:payment) Bridge code refactored: handleWebhook decomposed into verifySignature + parseEventBody + checkEventIdempotency + fulfillCheckout + ensureLicensePersisted (under ESLint complexity=20 cap). New createServer()/createRequestHandler() factories guarded by require.main === module. Removed 3 stale test files from the rolled-back DC-055 attempt.
This commit is contained in:
@@ -402,10 +402,14 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/api/v1/share/:token/preview', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/share/:token/subscribe', exact: true, method: 'POST' },
|
||||
{ path: '/api/v1/share/:token/redeem-tailscale', exact: true, method: 'POST' },
|
||||
// /api/v1/billing/* was REMOVED: billing is handled out-of-process
|
||||
// (the merchant webhook secret never enters the API process). The
|
||||
// PUBLIC_ROUTES allowlist drift test guards against re-adding these
|
||||
// dead entries.
|
||||
// /api/v1/billing/* (DC-055 + DC-057): public checkout session creation
|
||||
// + license lookup for the success page. No DashCaddy account exists
|
||||
// yet at checkout time. The lookup endpoint serves the persisted
|
||||
// license in both `delivered` and `pending_email` states (the SMTP
|
||||
// failure-recovery path); the bearer-style secret is the Stripe
|
||||
// Checkout sessionId (single-use, 24h TTL — see routes/billing.js).
|
||||
{ path: '/api/v1/billing/checkout', exact: true, method: 'POST' },
|
||||
{ path: '/api/v1/billing/lookup/:sessionId', exact: true, method: 'GET' },
|
||||
// /api/v1/services + status: read-only service metadata that the public
|
||||
// dashboard needs before login (services list widget, status pill).
|
||||
// Writes go through the normal auth gate. CSRF applies to writes as usual.
|
||||
|
||||
Reference in New Issue
Block a user