diff --git a/BACKLOG.md b/BACKLOG.md
index d5103c0..1ea41e1 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -338,13 +338,13 @@ Sami explicitly stated he wants email auth as an OPTION alongside TOTP, not a re
- **result:** Hermes sprint 2026-08-02 shipped the public-routes-drift half of DC-055 (commit 86df178, grade A): public-routes-drift.test.js now correctly maps `routes/billing.js` to `/billing` prefix in the walker (was previously bare-mount, so `/api/v1/billing/checkout` was flagged as stale drift) and adds `routes/services.js` to directMounts (was missing — `/api/v1/services` + `/api/v1/services/status` were incorrectly flagged stale). Removed dead `/api/v1/billing/webhook` PUBLIC_ROUTES entry — webhooks are handled out-of-process by `scripts/stripe-license-bridge.js` and the merchant webhook secret never enters the API process. The drift test now has 14/14 pass and the dead entry is documented in code so a re-add would fail loudly. Krystie's prior uncommitted work (`src/billing/stripe-client.js`, `routes/billing.js`, `scripts/stripe-license-bridge.js`, public pricing page, license-keygen LICENSE_SECRET_FILE refactor) is now buildable: full Jest suite is 1486/1486 green, zero new ESLint errors. Remaining for the actual pricing UI commit: verify the standalone `scripts/stripe-license-bridge.js` works against a real Stripe sandbox end-to-end, then add the pricing page to the Caddy file routing.
### DC-057: Close checkout-to-license contract drift before public billing launch
-- **status:** in-progress
+- **status:** done
- **owner:** hermes
- **details:** Codex audit found the current Stripe checkout and webhook bridge cannot interoperate: `dashcaddy-api/src/billing/stripe-client.js` emits `metadata: {tier, period, product}` while `dashcaddy-api/scripts/stripe-license-bridge.js` requires `metadata.sku`, so every paid Checkout completion returns `unknown-sku` and no license is delivered. The locked product decisions define one-time 30/90/180/365-day licenses at $20/$50/$70/$99, while the current pricing page and billing client present monthly/annual subscriptions. The product spec promises a license key on the success page, while the current page only redirects to Checkout and documents email-only delivery. The bridge comments and implementation also disagree about whether email failures are recorded for retry/idempotency.
- **impact:** Current billing can accept payment without issuing a license, which blocks public release and risks paid-customer support incidents.
- **prerequisite:** DC-054 and DC-055 working-tree billing artifacts are present but not yet released as a coherent, verified flow.
- **acceptance:** Define one canonical paid-product catalog shared by Checkout, webhook fulfillment, tests, and pricing labels; use the locked USD prices and one-time payment/duration semantics; feed the exact Checkout metadata into the bridge in a cross-module contract test; make duplicate/retry events unable to issue two keys; persist a recoverable license before email delivery and provide an explicit, tested SMTP-failure recovery path; reconcile success-page behavior, one-license-per-host wording, and legal/product copy with the actual secure delivery mechanism.
-- **result:** Rolled back to `todo` on 2026-08-02. The initial implementation attempt added an unintegrated catalog/fulfillment store but did not complete the client/bridge contract, crash-safe generation, production bridge topology/ingress, updater/systemd delivery, or lifetime-path audit. Preserve the evidence above for the next claimant and do not ship the partial working-tree artifacts.
+- **result:** Codex grade B. 1498/1498 Jest tests pass (62 suites), zero new ESLint warnings introduced. Shipped as one coherent DC-057 commit (no partial worktree artifacts). Single canonical product catalog (`src/billing/catalog.js`) shared by Checkout client, webhook bridge, pricing page, and catalog-consistency test. Stripe Checkout rewritten for **one-time payment** keyed by `productId` (`pro-30d`/`pro-90d`/`pro-180d`/`pro-365d`) at $20/$50/$70/$99, with `metadata.productId` as the single contract feeding the bridge — no SKU drift possible. Webhook bridge now requires `payment_status === 'paid'` before fulfillment (rejects unpaid/no_payment_required/missing with ack 200) and handles the ACH/SEPA delayed-payment flow via `checkout.session.async_payment_succeeded`. License is persisted to the durable fulfillment-store **before** email delivery; on SMTP failure, the lookup endpoint serves the persisted code in `pending_email` state (the documented recovery path) so the customer can save it manually. Layer-1 (event-id-keyed) and layer-2 (session-id-keyed) idempotency prevent duplicate issuance — a second webhook for the same Checkout Session ID reuses the persisted code, never generating a second key. Stripe Checkout return URLs are derived from `STRIPE_PUBLIC_ORIGIN` env var or `STRIPE_ALLOWED_HOSTS` allowlist (not raw `Host` header) — closes the host-header-poisoning + session-ID-leak class of attack. New success page (`status/billing/success.html`) reveals the license key with a copy button and polls the lookup endpoint every 1.5s. New test files: `stripe-license-bridge.test.js` (24 tests — signature, parsing, catalog resolution, idempotency, SMTP recovery, async payment events, lookupSession), `billing-lookup.test.js` (8 tests — HTTP-level route coverage of `/api/v1/billing/lookup/:sessionId` via real Express server), `bridge-lookup-http.test.js` (5 tests — bridge's own `/lookup/:sessionId` HTTP endpoint, uses exported `createServer()` factory so the SAME dispatcher the production server uses is exercised), `pricing-page-catalog.test.js` (9 tests — enforces consistency between catalog and the hardcoded pricing page at the per-tier level, plus success-page existence + lookup-endpoint reference), `checkout-origin.test.js` (6 tests — covers `STRIPE_PUBLIC_ORIGIN`, `STRIPE_ALLOWED_HOSTS`, host-header injection rejection, javascript: scheme rejection, http:// in production rejection). All 3 stale test files from the rolled-back DC-055 attempt removed (`__tests__/stripe-license-bridge.test.js`, `__tests__/routes/billing.test.js`). Bridge code refactored: `handleWebhook` decomposed into `verifySignature` + `parseEventBody` + `checkEventIdempotency` + `fulfillCheckout` + `ensureLicensePersisted` step functions (under ESLint complexity=20 cap). Production server created via exported `createServer()` / `createRequestHandler()` factories guarded by `require.main === module` so test imports don't leak an HTTP server. Pricing page (`status/pricing/index.html`) rewritten as 4 hardcoded tier cards with `data-product-id` attributes; old monthly/annual subscription toggle removed. Success page (`status/billing/success.html`) new — copy-button reveal, 1.5s polling, TTL-aware messages. To deploy: set `STRIPE_PRICE_PRO_30D/90D/180D/365D` env vars + `STRIPE_PUBLIC_ORIGIN=https://status.sami` (or set `STRIPE_ALLOWED_HOSTS=status.sami` for header-based fallback); configure the Stripe webhook endpoint to point at the bridge's `:3010/webhook` URL with the bridge's `STRIPE_WEBHOOK_SECRET`. Deploy the new pricing + success pages to `/var/www/dashcaddy-status/`. Bridge runs as `scripts/stripe-license-bridge.js` on port 3010.
### DC-056: ToS + Privacy Policy pages — GDPR-aware, no SOC2/HIPAA for v1.0
- **status:** done
diff --git a/dashcaddy-api/__tests__/billing/billing-lookup.test.js b/dashcaddy-api/__tests__/billing/billing-lookup.test.js
new file mode 100644
index 0000000..f1c7e3b
--- /dev/null
+++ b/dashcaddy-api/__tests__/billing/billing-lookup.test.js
@@ -0,0 +1,172 @@
+/**
+ * DC-057 billing lookup endpoint tests.
+ *
+ * Tests the GET /api/v1/billing/lookup/:sessionId route handler with a
+ * real fulfillment store on disk. Covers:
+ *
+ * - 404 for unknown sessionId
+ * - processing state (record exists, no code yet)
+ * - pending_email state — license persisted, email failed (SMTP recovery path)
+ * - delivered state
+ * - 404 past the 24h TTL
+ * - Cache-Control: no-store on all responses
+ * - Parameterized PUBLIC_ROUTES entry exists for this path
+ */
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const crypto = require('crypto');
+const express = require('express');
+
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-lookup-'));
+process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
+
+const billingRoutes = require('../../routes/billing');
+const { createFulfillmentStore } = require('../../src/billing/fulfillment-store');
+
+function makeApp() {
+ const app = express();
+ // Mock asyncHandler that calls the inner fn synchronously.
+ function asyncHandler(fn) {
+ return (req, res, next) => {
+ Promise.resolve(fn(req, res, next)).catch(next);
+ };
+ }
+ const router = billingRoutes({ asyncHandler });
+ app.use('/api/v1/billing', router);
+ return app;
+}
+
+function seedRecord(sessionId, overrides = {}) {
+ const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
+ // Plant a record directly via the mutation API.
+ return store.claim({
+ eventId: overrides.eventId || 'evt_seed',
+ sessionId,
+ productId: overrides.productId || 'pro-30d',
+ durationDays: overrides.durationDays || 30,
+ email: overrides.email || 'alice@example.com',
+ });
+}
+
+describe('GET /api/v1/billing/lookup/:sessionId', () => {
+ let app;
+ beforeAll(() => {
+ app = makeApp();
+ });
+
+ function get(sessionId) {
+ return new Promise((resolve, reject) => {
+ const server = app.listen(0, () => {
+ const port = server.address().port;
+ const http = require('http');
+ http.get(`http://127.0.0.1:${port}/api/v1/billing/lookup/${encodeURIComponent(sessionId)}`, (res) => {
+ let body = '';
+ res.on('data', (c) => { body += c; });
+ res.on('end', () => {
+ server.close();
+ resolve({ status: res.statusCode, headers: res.headers, body: body ? JSON.parse(body) : null });
+ });
+ }).on('error', reject);
+ });
+ });
+ }
+
+ test('returns 404 for unknown sessionId', async () => {
+ const res = await get('cs_unknown_session');
+ expect(res.status).toBe(404);
+ expect(res.body).toMatchObject({ success: false });
+ expect(res.headers['cache-control']).toBe('no-store');
+ });
+
+ test('returns 400 for invalid sessionId (too long)', async () => {
+ const longId = 'x'.repeat(300);
+ const res = await get(longId);
+ expect(res.status).toBe(400);
+ expect(res.headers['cache-control']).toBe('no-store');
+ });
+
+ test('returns processing state when record has no code yet', async () => {
+ const sessionId = `cs_proc_${crypto.randomBytes(4).toString('hex')}`;
+ await seedRecord(sessionId);
+
+ const res = await get(sessionId);
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ expect(res.body.data).toMatchObject({ status: 'processing', durationDays: 30, productId: 'pro-30d' });
+ expect(res.headers['cache-control']).toBe('no-store');
+ });
+
+ test('returns pending_email state with the persisted code (SMTP recovery)', async () => {
+ const sessionId = `cs_pending_${crypto.randomBytes(4).toString('hex')}`;
+ const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
+ await store.claim({ eventId: 'evt_1', sessionId, productId: 'pro-90d', durationDays: 90, email: 'a@b.c' });
+ await store.saveLicense({ eventId: 'evt_1', sessionId, code: 'DC-TEST-CODE-90D', codeId: 'cid_1' });
+ await store.claimDelivery({ sessionId, ownerToken: 'evt_1' });
+ await store.markDeliveryFailed({ sessionId, ownerToken: 'evt_1', error: 'smtp-down' });
+
+ const res = await get(sessionId);
+ expect(res.status).toBe(200);
+ expect(res.body.data).toMatchObject({
+ status: 'pending_email',
+ durationDays: 90,
+ productId: 'pro-90d',
+ code: 'DC-TEST-CODE-90D',
+ codeId: 'cid_1',
+ });
+ expect(res.body.data.lastError).toMatch(/smtp-down/);
+ });
+
+ test('returns delivered state with the code + deliveredVia', async () => {
+ const sessionId = `cs_delivered_${crypto.randomBytes(4).toString('hex')}`;
+ const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
+ await store.claim({ eventId: 'evt_2', sessionId, productId: 'pro-365d', durationDays: 365, email: 'a@b.c' });
+ await store.saveLicense({ eventId: 'evt_2', sessionId, code: 'DC-TEST-CODE-365D', codeId: 'cid_2' });
+ await store.claimDelivery({ sessionId, ownerToken: 'evt_2' });
+ await store.markDelivered({ sessionId, ownerToken: 'evt_2', deliveredVia: 'smtp' });
+
+ const res = await get(sessionId);
+ expect(res.status).toBe(200);
+ expect(res.body.data).toMatchObject({
+ status: 'delivered',
+ durationDays: 365,
+ productId: 'pro-365d',
+ code: 'DC-TEST-CODE-365D',
+ codeId: 'cid_2',
+ deliveredVia: 'smtp',
+ });
+ });
+
+ test('returns 404 past the 24h TTL', async () => {
+ const sessionId = `cs_old_${crypto.randomBytes(4).toString('hex')}`;
+ const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
+ await store.claim({ eventId: 'evt_old', sessionId, productId: 'pro-30d', durationDays: 30, email: 'a@b.c' });
+ await store.saveLicense({ eventId: 'evt_old', sessionId, code: 'DC-OLD', codeId: 'cid_old' });
+ await store.markDelivered({ sessionId, ownerToken: 'evt_old', deliveredVia: 'smtp' });
+
+ // Manually backdate the record's createdAt to be older than 24h.
+ const fs = require('fs');
+ const file = process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE;
+ const state = JSON.parse(fs.readFileSync(file, 'utf8'));
+ const r = state.bySessionId[sessionId];
+ r.createdAt = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
+ fs.writeFileSync(file, JSON.stringify(state, null, 2));
+
+ const res = await get(sessionId);
+ expect(res.status).toBe(404);
+ });
+});
+
+describe('PUBLIC_ROUTES + CSRF allowlist for billing/lookup', () => {
+ const fs = require('fs');
+ test('PUBLIC_ROUTES includes /api/v1/billing/lookup/:sessionId', () => {
+ const content = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'utilities', 'middleware.js'), 'utf8');
+ expect(content).toMatch(/path:\s*['"]\/api\/v1\/billing\/lookup\/:sessionId['"]/);
+ });
+
+ test('CSRF excludedPaths includes /api/v1/billing/lookup/:sessionId', () => {
+ const content = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'security', 'csrf-protection.js'), 'utf8');
+ expect(content).toMatch(/['"]\/api\/v1\/billing\/lookup\/:sessionId['"]/);
+ });
+});
diff --git a/dashcaddy-api/__tests__/billing/bridge-lookup-http.test.js b/dashcaddy-api/__tests__/billing/bridge-lookup-http.test.js
new file mode 100644
index 0000000..949f81d
--- /dev/null
+++ b/dashcaddy-api/__tests__/billing/bridge-lookup-http.test.js
@@ -0,0 +1,158 @@
+/**
+ * DC-057 bridge HTTP /lookup/:sessionId endpoint tests.
+ *
+ * Tests the bridge's own GET /lookup/:sessionId endpoint (separate from
+ * the API route). The bridge endpoint is for out-of-band operator use —
+ * the production customer lookup goes through routes/billing.js (covered
+ * by billing-lookup.test.js). But the bridge must still serve /lookup/*
+ * correctly for operator workflows and incident recovery.
+ */
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const crypto = require('crypto');
+
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-bridge-http-'));
+process.env.STRIPE_BRIDGE_STATE_DIR = TMP;
+process.env.STRIPE_BRIDGE_EVENTS_FILE = path.join(TMP, 'stripe-events.json');
+process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
+process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_' + crypto.randomBytes(8).toString('hex');
+delete process.env.SMTP_HOST;
+delete process.env.SMTP_FROM;
+
+jest.mock('../../license-keygen', () => {
+ // Use the built-in Date + Math.random instead of crypto so the jest.mock
+ // factory stays in scope (jest.mock factory bodies cannot reference
+ // outer-scope identifiers like `crypto`).
+ const mockRandom = () => Math.random().toString(16).slice(2, 10).toUpperCase();
+ let mockCounter = 0;
+ return {
+ VALID_DURATIONS: [30, 90, 180, 365],
+ loadSecret: () => 'mock-secret',
+ generateCodes: jest.fn(({ durationDays, count }) => {
+ const codes = [];
+ for (let i = 0; i < count; i++) {
+ codes.push({
+ code: `DC-TEST-${durationDays}D-${mockRandom()}`,
+ codeId: `cid_${Date.now()}_${i}_${++mockCounter}`,
+ });
+ }
+ return codes;
+ }),
+ };
+});
+jest.mock('nodemailer', () => ({
+ createTransport: () => ({ sendMail: jest.fn() }),
+}));
+
+const { createFulfillmentStore } = require('../../src/billing/fulfillment-store');
+const bridge = require('../../scripts/stripe-license-bridge');
+
+let server;
+let port;
+
+beforeAll((done) => {
+ // Use the bridge's own createServer() factory so the test exercises the
+ // SAME request dispatcher the production server uses (no duplicated
+ // route decoding / status mapping in test code).
+ server = bridge.createServer();
+ server.listen(0, () => {
+ port = server.address().port;
+ done();
+ });
+});
+
+afterAll((done) => {
+ server.close(done);
+});
+
+function get(path) {
+ return new Promise((resolve, reject) => {
+ http.get(`http://127.0.0.1:${port}${path}`, (res) => {
+ let body = '';
+ res.on('data', (c) => { body += c; });
+ res.on('end', () => {
+ resolve({ status: res.statusCode, headers: res.headers, body: body ? JSON.parse(body) : null });
+ });
+ }).on('error', reject);
+ });
+}
+
+describe('bridge GET /lookup/:sessionId', () => {
+ test('returns 404 for unknown sessionId', async () => {
+ const res = await get('/lookup/cs_unknown_session');
+ expect(res.status).toBe(404);
+ expect(res.body).toEqual({ status: 'not_found' });
+ expect(res.headers['cache-control']).toBe('no-store');
+ });
+
+ test('returns 400 for malformed percent-encoded sessionId', async () => {
+ // %ZZ is not valid hex.
+ const res = await get('/lookup/cs_%ZZ_bad');
+ expect(res.status).toBe(400);
+ expect(res.body.reason).toBe('invalid-session-id');
+ });
+
+ test('returns delivered state with code + deliveredVia for planted record', async () => {
+ const sessionId = `cs_test_delivered_${crypto.randomBytes(4).toString('hex')}`;
+ const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
+ await store.claim({ eventId: 'evt_1', sessionId, productId: 'pro-30d', durationDays: 30, email: 'a@b.c' });
+ await store.saveLicense({ eventId: 'evt_1', sessionId, code: 'DC-X', codeId: 'cid_1' });
+ await store.claimDelivery({ sessionId, ownerToken: 'evt_1' });
+ await store.markDelivered({ sessionId, ownerToken: 'evt_1', deliveredVia: 'smtp' });
+
+ const res = await get(`/lookup/${encodeURIComponent(sessionId)}`);
+ expect(res.status).toBe(200);
+ expect(res.body).toMatchObject({
+ status: 'delivered',
+ durationDays: 30,
+ productId: 'pro-30d',
+ code: 'DC-X',
+ codeId: 'cid_1',
+ deliveredVia: 'smtp',
+ });
+ expect(res.headers['cache-control']).toBe('no-store');
+ });
+
+ test('returns pending_email state (SMTP recovery)', async () => {
+ const sessionId = `cs_test_pending_${crypto.randomBytes(4).toString('hex')}`;
+ const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
+ await store.claim({ eventId: 'evt_2', sessionId, productId: 'pro-90d', durationDays: 90, email: 'a@b.c' });
+ await store.saveLicense({ eventId: 'evt_2', sessionId, code: 'DC-Y', codeId: 'cid_2' });
+ await store.claimDelivery({ sessionId, ownerToken: 'evt_2' });
+ await store.markDeliveryFailed({ sessionId, ownerToken: 'evt_2', error: 'smtp-down' });
+
+ const res = await get(`/lookup/${encodeURIComponent(sessionId)}`);
+ expect(res.status).toBe(200);
+ expect(res.body).toMatchObject({
+ status: 'pending_email',
+ durationDays: 90,
+ productId: 'pro-90d',
+ code: 'DC-Y',
+ codeId: 'cid_2',
+ });
+ expect(res.body.lastError).toMatch(/smtp-down/);
+ });
+
+ test('returns 404 past the 24h TTL', async () => {
+ const sessionId = `cs_test_old_${crypto.randomBytes(4).toString('hex')}`;
+ const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
+ await store.claim({ eventId: 'evt_3', sessionId, productId: 'pro-30d', durationDays: 30, email: 'a@b.c' });
+ await store.saveLicense({ eventId: 'evt_3', sessionId, code: 'DC-OLD', codeId: 'cid_3' });
+ await store.claimDelivery({ sessionId, ownerToken: 'evt_3' });
+ await store.markDelivered({ sessionId, ownerToken: 'evt_3', deliveredVia: 'smtp' });
+
+ // Backdate createdAt to be older than 24h.
+ const file = process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE;
+ const state = JSON.parse(fs.readFileSync(file, 'utf8'));
+ const r = state.bySessionId[sessionId];
+ r.createdAt = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
+ fs.writeFileSync(file, JSON.stringify(state, null, 2));
+
+ const res = await get(`/lookup/${encodeURIComponent(sessionId)}`);
+ expect(res.status).toBe(404);
+ expect(res.body).toEqual({ status: 'expired' });
+ });
+});
diff --git a/dashcaddy-api/__tests__/billing/checkout-origin.test.js b/dashcaddy-api/__tests__/billing/checkout-origin.test.js
new file mode 100644
index 0000000..b51bb07
--- /dev/null
+++ b/dashcaddy-api/__tests__/billing/checkout-origin.test.js
@@ -0,0 +1,227 @@
+/**
+ * DC-057 billing checkout origin resolution tests.
+ *
+ * The checkout endpoint embeds the success_url (and cancel_url) into the
+ * Stripe Checkout Session. These URLs are what Stripe redirects the
+ * customer's browser to after payment. They MUST be derived only from
+ * trusted sources — otherwise a header-injection attacker could redirect
+ * customers to their own origin and capture the session_id, which is
+ * the bearer token for /api/v1/billing/lookup/:sessionId (and that
+ * endpoint serves the customer's license code on success).
+ *
+ * The origin is resolved in this priority order:
+ * 1. STRIPE_PUBLIC_ORIGIN env var (canonical deployment shape)
+ * 2. Request Host header, but ONLY when the host is in
+ * STRIPE_ALLOWED_HOSTS (operator-declared allowlist)
+ * 3. undefined (Stripe falls back to its own defaults)
+ */
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const express = require('express');
+const http = require('http');
+
+// Save the fulfillment-store path so the route module captures the same
+// path the route would in production. (Tests below exercise the
+// stripe-client, not the fulfillment store, so the lookup endpoint can
+// share the same file.)
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-origin-'));
+process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
+
+const billingRoutes = require('../../routes/billing');
+const stripeClient = require('../../src/billing/stripe-client');
+
+const REQUIRED_ENV = {
+ STRIPE_SECRET_KEY: '«redacted:sk_test_…»',
+ STRIPE_PRICE_PRO_30D: 'price_30d_test',
+ STRIPE_PRICE_PRO_90D: 'price_90d_test',
+ STRIPE_PRICE_PRO_180D: 'price_180d_test',
+ STRIPE_PRICE_PRO_365D: 'price_365d_test',
+};
+
+function setEnv(overrides = {}) {
+ const all = { ...REQUIRED_ENV, ...overrides };
+ for (const [k, v] of Object.entries(all)) {
+ process.env[k] = v;
+ }
+}
+function clearEnv() {
+ for (const k of Object.keys(REQUIRED_ENV)) delete process.env[k];
+ delete process.env.STRIPE_PUBLIC_ORIGIN;
+ delete process.env.STRIPE_ALLOWED_HOSTS;
+ delete process.env.NODE_ENV;
+}
+
+function makeApp() {
+ const app = express();
+ app.use(require('express').json());
+ function asyncHandler(fn) {
+ return (req, res, next) => {
+ Promise.resolve(fn(req, res, next)).catch(next);
+ };
+ }
+ const router = billingRoutes({ asyncHandler });
+ app.use('/api/v1/billing', router);
+ return app;
+}
+
+function postCheckout(req, body, headers = {}) {
+ return new Promise((resolve, reject) => {
+ const server = req.listen(0, () => {
+ const port = server.address().port;
+ const data = JSON.stringify(body);
+ const headerLines = Object.entries({ 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers })
+ .map(([k, v]) => `${k}: ${v}`).join('\r\n');
+ const req2 = http.request({
+ hostname: '127.0.0.1', port, path: '/api/v1/billing/checkout', method: 'POST',
+ headers: Object.fromEntries(Object.entries({ 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }).map(([k, v]) => [k.toLowerCase(), v])),
+ }, (res) => {
+ let buf = '';
+ res.on('data', (c) => { buf += c; });
+ res.on('end', () => {
+ server.close();
+ resolve({ status: res.statusCode, headers: res.headers, body: buf ? JSON.parse(buf) : null });
+ });
+ });
+ req2.on('error', reject);
+ req2.write(data);
+ req2.end();
+ });
+ });
+}
+
+describe('POST /api/v1/billing/checkout — origin resolution (DC-057 security)', () => {
+ let app;
+ beforeAll(() => {
+ app = makeApp();
+ setEnv();
+ });
+ afterEach(() => {
+ clearEnv();
+ setEnv();
+ stripeClient._setStripeSdk(null);
+ });
+
+ test('uses STRIPE_PUBLIC_ORIGIN env var (canonical deployment shape)', async () => {
+ setEnv({ STRIPE_PUBLIC_ORIGIN: 'https://status.sami' });
+ const mockSession = { id: 'cs_test_orig_1', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_1' };
+ let capturedParams;
+ stripeClient._setStripeSdk(jest.fn().mockReturnValue({
+ checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
+ capturedParams = params;
+ return mockSession;
+ }) } },
+ }));
+
+ const res = await postCheckout(app, { productId: 'pro-30d' });
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ // The captured Stripe params must include the success_url + cancel_url
+ // built from the operator-declared origin — NOT from the request's Host
+ // header. This is the canonical deployment shape.
+ expect(capturedParams.success_url).toBe('https://status.sami/billing/success?session_id={CHECKOUT_SESSION_ID}');
+ expect(capturedParams.cancel_url).toBe('https://status.sami/pricing');
+ });
+
+ test('rejects Host header injection when STRIPE_ALLOWED_HOSTS is empty', async () => {
+ // Attacker sets X-Forwarded-Host: evil.com. The request reaches our
+ // endpoint. Without STRIPE_PUBLIC_ORIGIN + without STRIPE_ALLOWED_HOSTS,
+ // the origin must be undefined — we MUST NOT trust the attacker header.
+ setEnv({ STRIPE_ALLOWED_HOSTS: '' });
+ const mockSession = { id: 'cs_test_orig_2', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_2' };
+ let capturedParams;
+ stripeClient._setStripeSdk(jest.fn().mockReturnValue({
+ checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
+ capturedParams = params;
+ return mockSession;
+ }) } },
+ }));
+
+ const res = await postCheckout(app, { productId: 'pro-30d' }, {
+ 'X-Forwarded-Host': 'evil.com',
+ 'X-Forwarded-Proto': 'https',
+ });
+ expect(res.status).toBe(200);
+ // origin must be undefined when allowlist is empty — the Stripe SDK
+ // is called with undefined origin and the stripe-client falls back to
+ // relative '/billing/success' which is safe (no host poisoning).
+ expect(capturedParams.success_url).toMatch(/^\/billing\/success/);
+ });
+
+ test('accepts Host header when STRIPE_ALLOWED_HOSTS includes it', async () => {
+ setEnv({ STRIPE_ALLOWED_HOSTS: 'status.sami,dashcaddy.net' });
+ const mockSession = { id: 'cs_test_orig_3', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_3' };
+ let capturedParams;
+ stripeClient._setStripeSdk(jest.fn().mockReturnValue({
+ checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
+ capturedParams = params;
+ return mockSession;
+ }) } },
+ }));
+
+ const res = await postCheckout(app, { productId: 'pro-30d' }, {
+ 'X-Forwarded-Host': 'status.sami',
+ 'X-Forwarded-Proto': 'https',
+ });
+ expect(res.status).toBe(200);
+ expect(capturedParams.success_url).toContain('status.sami');
+ expect(capturedParams.success_url).toContain('/billing/success');
+ });
+
+ test('rejects Host header when host is NOT in STRIPE_ALLOWED_HOSTS', async () => {
+ setEnv({ STRIPE_ALLOWED_HOSTS: 'status.sami' });
+ const mockSession = { id: 'cs_test_orig_4', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_4' };
+ let capturedParams;
+ stripeClient._setStripeSdk(jest.fn().mockReturnValue({
+ checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
+ capturedParams = params;
+ return mockSession;
+ }) } },
+ }));
+
+ const res = await postCheckout(app, { productId: 'pro-30d' }, {
+ 'X-Forwarded-Host': 'evil.com',
+ 'X-Forwarded-Proto': 'https',
+ });
+ expect(res.status).toBe(200);
+ // origin is undefined → relative /billing/success URL (safe).
+ expect(capturedParams.success_url).toMatch(/^\/billing\/success/);
+ expect(capturedParams.success_url).not.toContain('evil.com');
+ });
+
+ test('rejects javascript: scheme injection via STRIPE_PUBLIC_ORIGIN', async () => {
+ setEnv({ STRIPE_PUBLIC_ORIGIN: 'javascript:alert(1)' });
+ const mockSession = { id: 'cs_test_orig_5', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_5' };
+ let capturedParams;
+ stripeClient._setStripeSdk(jest.fn().mockReturnValue({
+ checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
+ capturedParams = params;
+ return mockSession;
+ }) } },
+ }));
+
+ const res = await postCheckout(app, { productId: 'pro-30d' });
+ expect(res.status).toBe(200);
+ // javascript: scheme is rejected; origin falls through to header-based
+ // resolution, which is also gated by STRIPE_ALLOWED_HOSTS (empty here).
+ expect(capturedParams.success_url).not.toMatch(/javascript:/);
+ });
+
+ test('rejects http:// in production when NODE_ENV=production', async () => {
+ setEnv({ STRIPE_PUBLIC_ORIGIN: 'http://status.sami', NODE_ENV: 'production' });
+ const mockSession = { id: 'cs_test_orig_6', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_6' };
+ let capturedParams;
+ stripeClient._setStripeSdk(jest.fn().mockReturnValue({
+ checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
+ capturedParams = params;
+ return mockSession;
+ }) } },
+ }));
+
+ const res = await postCheckout(app, { productId: 'pro-30d' });
+ expect(res.status).toBe(200);
+ // http:// rejected in production; origin falls back to undefined.
+ expect(capturedParams.success_url).not.toMatch(/^http:/);
+ });
+});
diff --git a/dashcaddy-api/__tests__/billing/pricing-page-catalog.test.js b/dashcaddy-api/__tests__/billing/pricing-page-catalog.test.js
new file mode 100644
index 0000000..bae3eaf
--- /dev/null
+++ b/dashcaddy-api/__tests__/billing/pricing-page-catalog.test.js
@@ -0,0 +1,134 @@
+/**
+ * DC-057 pricing-page catalog consistency test.
+ *
+ * The pricing page at status/pricing/index.html hard-codes the 4 product
+ * IDs, prices, and labels. This test asserts that those hard-coded values
+ * exactly match the catalog in src/billing/catalog.js — preventing drift
+ * between the two sources.
+ *
+ * If a new tier is added to the catalog, this test will fail until the
+ * pricing page is updated. If the pricing page is updated, the catalog
+ * must change in lockstep (or this test fails the other way).
+ */
+
+const fs = require('fs');
+const path = require('path');
+const catalog = require('../../src/billing/catalog');
+
+const PRICING_PAGE_PATH = path.join(__dirname, '..', '..', '..', 'status', 'pricing', 'index.html');
+
+function extractTiersFromPage(html) {
+ // Extract each `
` block, then
+ // pull out the dollar amount in the `
` element and
+ // the durationDays from the "N-day Pro license" string. The regex is
+ // anchored on the tier-class open + the matching buy-btn close so we
+ // capture the full body of each tier card regardless of how many inner
+ // divs it has.
+ const tierRe = /