DC-057: close checkout-to-license contract drift (grade B)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

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:
Hermes
2026-08-04 14:18:49 -07:00
parent f154f501ff
commit 9b9711bf24
19 changed files with 3399 additions and 26 deletions
@@ -0,0 +1,763 @@
#!/usr/bin/env node
/**
* DashCaddy Stripe license bridge — DC-054 + DC-057.
*
* Tiny HTTP webhook listener that converts a Stripe Checkout completion into
* a DashCaddy Pro license code + a confirmation email. Runs as its own
* process (NOT inside the DashCaddy API) so the merchant's Stripe secret
* material stays out of the host-side process tree.
*
* # Wire format
*
* POST /webhook
* Stripe-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>
* <raw JSON body>
*
* The body for `checkout.session.completed` carries:
* { id, customer_email, metadata: { productId }, amount_total, currency, ... }
*
* # Catalog contract (DC-057)
*
* `metadata.productId` is one of the IDs in src/billing/catalog.js:
* pro-30d | pro-90d | pro-180d | pro-365d
* The bridge maps productId → duration via the catalog (single source of
* truth shared with the Checkout client + pricing page). The catalog's
* configured Stripe Price is read via `STRIPE_PRICE_PRO_*D` env vars
* (already documented in the catalog) and is used to validate that the
* product is purchasable (configuredPriceId is not empty) — NOT to
* cross-validate the price against the customer's Stripe session. Price
* verification is intentionally omitted because (a) Stripe webhooks do
* not include expanded line_items by default and (b) trusting the price
* would block legitimate customers during a price rollover.
*
* The previous `STRIPE_SKU_*` env vars are REMOVED in DC-057 — operators
* who set them should migrate to `STRIPE_PRICE_PRO_*D`.
*
* # Generation flow
*
* 1. Verify Stripe-Signature (constant-time HMAC-SHA256 compare).
* Reject with 400 if the timestamp is more than TOLERANCE_SECONDS
* old, or if any v1 signature is missing.
* 2. Look up the event in the per-event idempotency file
* (data/stripe-events.json). If seen, replay the previous response
* status (200) WITHOUT regenerating. This is the layer-1 idempotency.
* 3. If the event type isn't `checkout.session.completed`, ack with
* `{delivered: false, reason: "ignored-event-type"}` so Stripe stops
* retrying, and record the event.
* 4. Parse the session metadata; resolve productId via the catalog.
* 5. CLAIM the fulfillment record (atomic, sessionId-keyed).
* - If the record exists for this sessionId in `pending_email` or
* `delivered` state (e.g. a previous webhook delivery succeeded OR
* saved a license but email failed), we REUSE the persisted license
* code — never generate a second one. This is the layer-2
* idempotency keyed by Checkout Session ID (globally unique, never
* reused even when the same event is replayed).
* - If the record exists in `generating` state and the lease is held
* by a DIFFERENT event (concurrent webhook fan-out), respond 409
* so Stripe retries — only one delivery wins.
* 6. SAVE the license code into the fulfillment record
* (data/stripe-fulfillments.json) BEFORE attempting email. This is
* the crash-safety guarantee: even if email fails AND the process
* is killed, the license is durably persisted.
* 7. DELIVER the code via SMTP (or dev-console fallback).
* - On success: markDelivered. Lookup endpoint serves the code on
* the success page.
* - On failure: markDeliveryFailed → reverts to pending_email. Lookup
* endpoint serves the code ANYWAY (with a "(email delivery didn't
* complete)" notice) — customer can save it manually. Subsequent
* webhook retries reuse the same persisted code via step 5.
* 8. Respond 500 to Stripe ONLY if the license save succeeded but email
* failed AND no successful delivery record exists — Stripe will
* retry. If delivery already succeeded earlier, ack 200.
*
* # SMTP transport
*
* Reuses the same env vars as DashCaddy's notification system:
* SMTP_HOST / SMTP_PORT / SMTP_USERNAME / SMTP_PASSWORD / SMTP_FROM / SMTP_SECURE
* If HOST or FROM is missing, delivery falls back to dev-console mode
* (the bridge logs the full email body so the operator can deliver it
* manually). This is the documented dev path; do NOT enable it in
* production.
*
* # Exit codes
*
* 0 — clean shutdown
* 1 — fatal startup error (missing secret, port bind failure, no
* products configured)
* 2 — runtime error while handling a request (logged, 500 returned)
*
* # Security notes
*
* - Webhook signature MUST verify BEFORE any JSON parsing. The raw body
* is opaque until HMAC checks out.
* - Timing-safe signature comparison (crypto.timingSafeEqual).
* - The fulfillment-store file lives in platformPaths.dataDir (bind-
* mounted in production). Atomic writes + per-mutation mutex.
*
* Tested in __tests__/billing/stripe-license-bridge.test.js (no live network).
*/
'use strict';
const http = require('http');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { generateCodes, loadSecret } = require('../license-keygen');
const platformPaths = require('../platform-paths');
const catalog = require('../src/billing/catalog');
const { createFulfillmentStore, DELIVERY_LEASE_MS } = require('../src/billing/fulfillment-store');
// ── Configuration (env-driven) ──────────────────────────────────────────────
const PORT = parseInt(process.env.STRIPE_BRIDGE_PORT || '3010', 10);
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || '';
const TOLERANCE_SECONDS = parseInt(process.env.STRIPE_BRIDGE_TOLERANCE || '300', 10);
// SMTP. Falls back to dev-console mode if HOST or FROM is missing.
// Read at function-call time (not module load) so tests can toggle SMTP
// behavior between cases without re-requiring the bridge.
function _smtpConfig() {
return {
host: process.env.SMTP_HOST || '',
port: parseInt(process.env.SMTP_PORT || '587', 10),
secure: process.env.SMTP_SECURE === 'true',
username: process.env.SMTP_USERNAME || '',
password: process.env.SMTP_PASSWORD || '',
from: process.env.SMTP_FROM || '',
};
}
// State files (atomic write). Override paths in tests.
const STATE_DIR = process.env.STRIPE_BRIDGE_STATE_DIR || platformPaths.dataDir;
const EVENTS_FILE = process.env.STRIPE_BRIDGE_EVENTS_FILE || path.join(STATE_DIR, 'stripe-events.json');
const FULFILLMENT_STORE = process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE
|| path.join(STATE_DIR, 'stripe-fulfillments.json');
// Lookup TTL: after a license has been "delivered" for this long, the
// /api/v1/billing/lookup/:sessionId endpoint returns 404 even with a valid
// sessionId. 24 hours matches Stripe's default Checkout session expiry and
// is far longer than any customer needs to paste their key.
const LOOKUP_TTL_MS = parseInt(process.env.STRIPE_BRIDGE_LOOKUP_TTL_MS || String(24 * 60 * 60 * 1000), 10);
// Build the fulfillment store singleton used by both bridge writes and
// lookup reads (the API's lookup endpoint reads from the SAME file via
// its own createFulfillmentStore() instance — file is the IPC channel).
const fulfillmentStore = createFulfillmentStore({ filePath: FULFILLMENT_STORE });
// ── Helpers ────────────────────────────────────────────────────────────────
function log(level, msg, meta) {
const line = JSON.stringify({ ts: new Date().toISOString(), level, msg, ...(meta || {}) });
process.stdout.write(line + '\n');
}
/**
* Verify a Stripe-Signature header. Returns { ok: true } if the signature is
* well-formed, within tolerance, AND matches at least one v1 entry.
* Returns { ok: false, reason } otherwise — callers MUST 400 on failure.
*
* Format: t=<unix_ts>,v1=<hex>[,v1=<hex>]*
*
* The signed payload is `${t}.${rawBody}`. We recompute HMAC-SHA256 of that
* exact byte sequence with the webhook secret, then timing-safe-compare
* against each v1 entry until one matches. Multiple v1 entries are allowed
* during Stripe secret rotation; we just need one to verify.
*/
function verifyStripeSignature(rawBody, header, secret, nowSec) {
if (!header || typeof header !== 'string') return { ok: false, reason: 'missing-signature' };
if (!secret) return { ok: false, reason: 'no-server-secret' };
const parts = header.split(',').map((s) => s.trim()).filter(Boolean);
let timestamp = null;
const v1List = [];
for (const part of parts) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const key = part.slice(0, eq);
const val = part.slice(eq + 1);
if (key === 't') timestamp = parseInt(val, 10);
else if (key === 'v1') v1List.push(val);
}
if (!Number.isFinite(timestamp)) return { ok: false, reason: 'missing-timestamp' };
if (v1List.length === 0) return { ok: false, reason: 'missing-v1' };
const skew = Math.abs((nowSec || Math.floor(Date.now() / 1000)) - timestamp);
if (skew > TOLERANCE_SECONDS) return { ok: false, reason: 'timestamp-out-of-tolerance' };
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`, 'utf8').digest();
for (const v1 of v1List) {
let got;
try {
got = Buffer.from(v1, 'hex');
} catch (_) {
continue;
}
if (got.length !== expected.length) continue;
if (crypto.timingSafeEqual(got, expected)) return { ok: true };
}
return { ok: false, reason: 'no-matching-signature' };
}
// ── Idempotency store (event-id-keyed layer 1) ─────────────────────────────
function readEvents() {
try {
const raw = fs.readFileSync(EVENTS_FILE, 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && parsed.events && typeof parsed.events === 'object') {
return parsed;
}
return { events: {} };
} catch (err) {
if (err && err.code === 'ENOENT') return { events: {} };
// Treat any parse error as an empty store — the next successful write
// will replace the file. Worst case we re-deliver; Stripe tolerates
// duplicate emails.
return { events: {} };
}
}
function writeEvents(state) {
// Atomic write: tmp + rename.
const tmp = `${EVENTS_FILE}.tmp.${process.pid}.${Date.now()}`;
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
fs.renameSync(tmp, EVENTS_FILE);
}
function recordEvent(eventId, meta) {
const state = readEvents();
if (state.events[eventId]) return false; // already delivered
state.events[eventId] = {
receivedAt: new Date().toISOString(),
...(meta || {}),
};
writeEvents(state);
return true;
}
function eventSeen(eventId) {
const state = readEvents();
return Boolean(state.events[eventId]);
}
// ── Email delivery ─────────────────────────────────────────────────────────
/**
* Send the license key email. If SMTP is configured, real send via
* nodemailer; if not, log the full email body to stdout so the operator
* can deliver manually in dev/test environments.
*
* Returns { delivered: bool, via: 'smtp' | 'dev-console' }.
*/
async function deliverCode({ to, code, durationDays, eventId, productId }) {
const subject = `Your DashCaddy Pro license (${durationDays} days)`;
const text = [
'Thank you for purchasing DashCaddy Pro.',
'',
`Your license key is valid for ${durationDays} days:`,
'',
` ${code}`,
'',
'To install on your DashCaddy host:',
' 1. Open https://<your-host>/admin/license',
' 2. Paste the key into the "Activate license" field',
' 3. Submit — Pro features unlock immediately.',
'',
'The same key is also revealed on your purchase success page; keep it safe.',
'',
'Need help? Reply to this email and we will assist.',
'',
`Reference: ${eventId}`,
`Product: ${productId}`,
].join('\n');
const smtp = _smtpConfig();
if (!smtp.host || !smtp.from) {
// Dev-console fallback: log the full email body to stdout so the
// operator can deliver manually in dev/test environments. The
// fulfillment record is marked `delivered` with `via: 'dev-console'`
// so the lookup endpoint serves the code on the success page — the
// operator seeing the bridge logs IS the documented delivery path
// when SMTP is unconfigured. In production, the bridge refuses to
// boot without SMTP configured (see checkFatalConfig).
log('info', 'smtp-not-configured, falling back to dev-console delivery', { to, durationDays, code });
return { delivered: true, via: 'dev-console' };
}
// Lazy-load nodemailer so the test suite doesn't pull it into coverage.
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: smtp.host,
port: smtp.port,
secure: smtp.secure,
auth: smtp.username ? { user: smtp.username, pass: smtp.password } : undefined,
tls: { rejectUnauthorized: process.env.NODE_ENV === 'production' },
});
await transporter.sendMail({ from: smtp.from, to, subject, text });
return { delivered: true, via: 'smtp' };
}
// ── Request handler ────────────────────────────────────────────────────────
/**
* Resolve a Stripe session to a catalog product + duration.
*
* The source of truth is `metadata.productId` — which the stripe-client
* sets when it creates the Checkout Session (see stripe-client.js).
* The customer is identified by the product they intended to buy, NOT
* by the Stripe Price ID at fulfillment time, because:
*
* - A repoint of STRIPE_PRICE_PRO_30D to a new Stripe Price affects
* NEW Checkout Sessions only. Existing sessions retain their
* original line_items.price.id; their metadata.productId is
* unchanged. Trusting price would force the operator to keep the
* old Price ID configured indefinitely (or forever block customers
* who started checkout before the rollover).
* - Stripe does not include expanded line_items in webhook payloads
* by default. To get them we'd need either a separate
* stripe.checkout.sessions.retrieve() call per webhook or Stripe's
* webhook-expansion feature. Neither is worth the cost when the
* metadata is already a complete canonical identifier.
*
* Returns { product, durationDays } on success or { error, ...details } on failure.
*/
function resolveProductFromSession(session) {
if (!session || typeof session !== 'object') return null;
const productId = session.metadata && session.metadata.productId;
if (!productId) return { error: 'missing-productId' };
const product = catalog.getProduct(productId);
if (!product) return { error: 'unknown-productId', productId };
const configuredPriceId = catalog.getConfiguredPrice(product);
if (!configuredPriceId) return { error: 'product-not-configured', productId, missing: product.priceEnv };
return { product, durationDays: product.durationDays };
}
/**
* Process one webhook delivery. Pure-ish: takes the raw body, signature
* header, and event id; returns an HTTP-friendly result object.
*
* Exported for tests. The HTTP wrapper below calls this with the parsed
* inputs and turns the result into a response.
*
* Composed of small step functions to keep individual cyclomatic complexity
* under ESLint's limit of 20.
*/
async function handleWebhook({ rawBody, signatureHeader, eventId }) {
const sigResult = verifySignature(rawBody, signatureHeader);
if (sigResult) return sigResult;
const event = parseEventBody(rawBody);
if (event.error) return event.error;
const id = eventId || event.body.id;
const dup = checkEventIdempotency(id);
if (dup) return dup;
// Only handle the FULFILLMENT_EVENT_TYPES below. Everything else falls
// into the ACK_ONLY_EVENT_TYPES or the catch-all ignored branch.
//
// - `checkout.session.completed` — fires for ALL completed sessions
// (paid OR unpaid). We only fulfill when payment_status='paid'.
// - `checkout.session.async_payment_succeeded` — fires for delayed
// payment methods (ACH/SEPA/bank debits) when the bank clears.
// Stripe sends `checkout.session.completed` first (unpaid), then
// this event when the payment confirms. payment_status is always
// 'paid' on this event.
// - `checkout.session.async_payment_failed` — ack-only; the customer
// must retry from the pricing page.
const FULFILLMENT_EVENT_TYPES = new Set([
'checkout.session.completed',
'checkout.session.async_payment_succeeded',
]);
const ACK_ONLY_EVENT_TYPES = new Set([
'checkout.session.async_payment_failed',
]);
if (!FULFILLMENT_EVENT_TYPES.has(event.body.type)) {
if (ACK_ONLY_EVENT_TYPES.has(event.body.type)) {
// Permanent failure for delayed payments. Ack 200 so Stripe
// stops retrying; the customer must retry from the pricing page.
recordEvent(id, { ignoredType: event.body.type });
return { status: 200, body: { delivered: false, reason: 'async-payment-failed' } };
}
recordEvent(id, { ignoredType: event.body.type });
return { status: 200, body: { delivered: false, reason: 'ignored-event-type' } };
}
const session = event.body.data && event.body.data.object;
if (!session || typeof session !== 'object') {
return { status: 400, body: { delivered: false, reason: 'missing-session' } };
}
// Guard: only fulfill PAID sessions. Stripe sends
// `checkout.session.completed` for BOTH paid AND unpaid events (e.g.
// when the customer closes the browser mid-checkout). The `payment_status`
// field on the session object disambiguates:
// - 'paid' — payment succeeded; we generate the license.
// - 'unpaid' — delayed-payment method (ACH/SEPA) not yet cleared;
// the async_payment_succeeded event will fire later and we
// generate the license then. Ack 200 here so Stripe stops
// retrying (the async event will be the fulfillment trigger).
// - 'no_payment_required' — Stripe-internal edge case for free
// sessions. DashCaddy doesn't sell any, so we reject.
// - absent — Stripe sometimes omits it on incomplete sessions;
// reject to be safe.
const paymentStatus = session.payment_status;
if (paymentStatus !== 'paid') {
recordEvent(id, { ignoredType: event.body.type, paymentStatus });
return { status: 200, body: { delivered: false, reason: `payment-not-${paymentStatus || 'confirmed'}` } };
}
return await fulfillCheckout({ id, session });
}
function verifySignature(rawBody, signatureHeader) {
const nowSec = Math.floor(Date.now() / 1000);
const sigCheck = verifyStripeSignature(rawBody, signatureHeader, WEBHOOK_SECRET, nowSec);
if (!sigCheck.ok) {
return { status: 400, body: { delivered: false, reason: `signature-${sigCheck.reason}` } };
}
return null;
}
function parseEventBody(rawBody) {
let event;
try {
event = JSON.parse(rawBody.toString('utf8'));
} catch (_) {
return { error: { status: 400, body: { delivered: false, reason: 'invalid-json' } } };
}
if (!event || typeof event !== 'object' || !event.id) {
return { error: { status: 400, body: { delivered: false, reason: 'invalid-event' } } };
}
return { body: event };
}
function checkEventIdempotency(id) {
if (eventSeen(id)) {
return { status: 200, body: { delivered: true, deduplicated: true } };
}
return null;
}
/**
* Run the catalog → claim → deliver pipeline for one checkout session.
* Returns the final HTTP-friendly result.
*/
async function fulfillCheckout({ id, session }) {
const resolution = resolveProductFromSession(session);
if (!resolution || resolution.error) {
const reason = resolution && resolution.error ? resolution.error : 'missing-productId';
log('warn', 'catalog-resolution-failed', { eventId: id, reason, ...(resolution || {}) });
return { status: 400, body: { delivered: false, reason } };
}
const { product, durationDays } = resolution;
const email = session.customer_email || (session.customer_details && session.customer_details.email) || '';
if (!email) return { status: 400, body: { delivered: false, reason: 'missing-customer-email' } };
const sessionId = session.id || '';
if (!sessionId) return { status: 400, body: { delivered: false, reason: 'missing-session-id' } };
const claim = await fulfillmentStore.claim({
eventId: id, sessionId, productId: product.id, durationDays, email,
});
if (claim.busy) {
return { status: 409, body: { delivered: false, reason: 'concurrent-delivery', retryable: true } };
}
const licenseResult = await ensureLicensePersisted({ id, sessionId, product, claim, durationDays });
if (licenseResult.error) return licenseResult.error;
const { code, codeId } = licenseResult;
const deliveryClaim = await fulfillmentStore.claimDelivery({ sessionId, ownerToken: id });
if (deliveryClaim.busy) {
return { status: 409, body: { delivered: false, reason: 'concurrent-delivery', retryable: true } };
}
let delivery;
try {
delivery = await deliverCode({ to: email, code, durationDays, eventId: id, productId: product.id });
} catch (err) {
log('error', 'email-delivery-failed', { eventId: id, sessionId, error: err.message });
await fulfillmentStore.markDeliveryFailed({ sessionId, ownerToken: id, error: err.message });
return { status: 500, body: { delivered: false, reason: 'email-failed', error: err.message } };
}
await fulfillmentStore.markDelivered({ sessionId, ownerToken: id, deliveredVia: delivery.via });
recordEvent(id, { durationDays, codeId, productId: product.id, email, deliveredVia: delivery.via });
return {
status: 200,
body: { delivered: true, codeId, productId: product.id, durationDays, deliveredVia: delivery.via },
};
}
/**
* Either reuse an existing persisted code (layer-2 idempotency) or
* generate + persist a fresh one. Returns { code, codeId } or
* { error: <http-result> }.
*/
async function ensureLicensePersisted({ id, sessionId, product, claim, durationDays }) {
const existing = claim.record;
if (existing.code) {
// Reusing an existing license (from a previous successful or failed
// attempt for the SAME session). This is the retry-safe path.
log('info', 'license-reused-from-fulfillment-store', {
eventId: id, sessionId, productId: product.id, status: existing.status,
});
return { code: existing.code, codeId: existing.codeId };
}
let secret;
try {
secret = loadSecret();
} catch (err) {
log('error', 'license-secret-missing', { error: err.message });
return { error: { status: 500, body: { delivered: false, reason: 'server-not-configured' } } };
}
const codes = generateCodes({ secret, durationDays, count: 1 });
const code = codes[0].code;
const codeId = codes[0].codeId;
// Persist BEFORE attempting email. From this point on, the license is
// durable even if the process dies or email fails.
const saveResult = await fulfillmentStore.saveLicense({ eventId: id, sessionId, code, codeId });
if (!saveResult.saved) {
log('error', 'license-save-rejected', { eventId: id, sessionId, record: saveResult.record });
return { error: { status: 500, body: { delivered: false, reason: 'save-rejected' } } };
}
return { code, codeId };
}
/**
* Read a fulfillment record for the public lookup endpoint.
*
* Returns:
* { status: 'not_found' } — no record exists (session not paid / unknown)
* { status: 'expired' } — record exists but is past the lookup TTL
* { status: 'processing', durationDays } — license being generated
* { status: 'pending_email', durationDays, code, codeId, deliveredVia? } — license persisted, email failed
* { status: 'delivered', durationDays, code, codeId, deliveredVia } — license delivered
*
* The lookup endpoint serves the persisted code in BOTH pending_email AND
* delivered states — that is the documented SMTP-failure recovery path
* (the customer pastes their key even if email failed).
*/
function lookupSession(sessionId, { nowMs = Date.now() } = {}) {
const record = fulfillmentStore.readBySession(sessionId);
if (!record) return { status: 'not_found' };
const createdAt = record.createdAt ? Date.parse(record.createdAt) : nowMs;
const ageMs = nowMs - createdAt;
if (Number.isFinite(ageMs) && ageMs > LOOKUP_TTL_MS) {
return { status: 'expired' };
}
if (record.status === 'generating') {
return { status: 'processing', durationDays: record.durationDays, productId: record.productId };
}
if (!record.code) {
// Should not happen after saveLicense() succeeds, but defensive.
return { status: 'processing', durationDays: record.durationDays, productId: record.productId };
}
const base = {
durationDays: record.durationDays,
code: record.code,
codeId: record.codeId,
productId: record.productId,
};
if (record.status === 'delivered') {
return { status: 'delivered', deliveredVia: record.deliveredVia || 'unknown', ...base };
}
// pending_email OR delivering — license is durably persisted.
return { status: 'pending_email', deliveredVia: record.deliveredVia, lastError: record.lastError, ...base };
}
// ── HTTP server ────────────────────────────────────────────────────────────
const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MB — Stripe events are small.
function readRawBody(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
reject(new Error('body-too-large'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function writeJson(res, status, body, extraHeaders = {}) {
const text = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(text),
// License codes are bearer-style secrets — never cache them.
'Cache-Control': 'no-store',
...extraHeaders,
});
res.end(text);
}
/**
* Create an HTTP request handler for the bridge (testable as a factory).
*
* Routes:
* POST /webhook — Stripe checkout.session.completed webhooks
* GET /lookup/<sessionId> — operator / incident-recovery lookup
*
* Exported for tests so they can drive the SAME handler logic the
* production bridge server uses (instead of duplicating the route
* dispatch in test code).
*/
function createRequestHandler() {
return async function handleBridgeRequest(req, res) {
if (req.method === 'POST' && req.url === '/webhook') {
let rawBody;
try {
rawBody = await readRawBody(req);
} catch (err) {
writeJson(res, 413, { delivered: false, reason: 'body-too-large' });
return;
}
const signatureHeader = req.headers['stripe-signature'] || '';
let result;
try {
result = await handleWebhook({ rawBody, signatureHeader });
} catch (err) {
log('error', 'handler-threw', { error: err.message, stack: err.stack });
writeJson(res, 500, { delivered: false, reason: 'handler-error' });
return;
}
writeJson(res, result.status, result.body);
return;
}
if (req.method === 'GET' && req.url && req.url.startsWith('/lookup/')) {
// The bridge exposes a /lookup/<sessionId> endpoint as a convenience
// for out-of-band operators (manual incident recovery, cron jobs that
// scan pending_email records, etc.). The PRODUCTION lookup endpoint
// for the success page is the API route at /api/v1/billing/lookup/*,
// which reads the same fulfillment-store file but lives in the API
// process (so the customer-facing response path doesn't depend on the
// bridge being up). This endpoint is only useful when the API is
// unreachable but the bridge is — and the bridge itself can fail to
// boot without it.
//
// decodeURIComponent throws on malformed percent-encoding. We catch
// that explicitly to surface a clean 400 instead of a 500.
const rawSessionId = req.url.slice('/lookup/'.length).split('?')[0];
let sessionId;
try {
sessionId = decodeURIComponent(rawSessionId);
} catch (_) {
writeJson(res, 400, { delivered: false, reason: 'invalid-session-id' });
return;
}
const result = lookupSession(sessionId);
const httpStatus = result.status === 'not_found' || result.status === 'expired' ? 404 : 200;
writeJson(res, httpStatus, result);
return;
}
writeJson(res, 404, { delivered: false, reason: 'not-found' });
};
}
/**
* Create an HTTP server bound to the bridge request handler. Returns the
* server WITHOUT starting it — callers call `.listen(port)` themselves.
*
* Production entrypoint uses this factory; tests can use
* `bridge.createRequestHandler()` to wire the same dispatch logic
* without spinning up an HTTP server.
*/
function createServer() {
return http.createServer(createRequestHandler());
}
// Module-level server variable. Created by `createServer()` only when the
// bridge is the entrypoint (`require.main === module`); tests + libraries
// that require the bridge leave this unset.
let server;
if (require.main === module) {
server = createServer();
}
// ── Bootstrap ──────────────────────────────────────────────────────────────
function checkFatalConfig() {
const missing = [];
if (!WEBHOOK_SECRET) missing.push('STRIPE_WEBHOOK_SECRET');
// At least one product must be configured for purchase.
const configured = catalog.getConfiguredProducts().filter((p) => p.priceId);
if (configured.length === 0) {
const allEnvs = catalog.PRODUCTS.map((p) => p.priceEnv);
missing.push(`at-least-one-of-${allEnvs.join('|')}`);
}
return missing;
}
/**
* Module exports — for tests. Production entrypoint is the `if
* (require.main === module)` block below.
*/
module.exports = {
verifyStripeSignature,
handleWebhook,
readEvents,
eventSeen,
recordEvent,
writeEvents,
resolveProductFromSession,
lookupSession,
// Server factories — tests can call createRequestHandler() to wire
// the same dispatch logic the production server uses, without
// duplicating route decoding / status mapping in test code.
createRequestHandler,
createServer,
// Constants exposed so tests can pin them when running in parallel.
TOLERANCE_SECONDS,
LOOKUP_TTL_MS,
DELIVERY_LEASE_MS,
MAX_BODY_BYTES,
};
if (require.main === module) {
const missing = checkFatalConfig();
if (missing.length > 0) {
log('error', 'startup-misconfigured', { missing });
process.exit(1);
}
server.listen(PORT, () => {
const smtp = _smtpConfig();
log('info', 'stripe-license-bridge-listening', {
port: PORT,
configuredProducts: catalog.getConfiguredProducts()
.filter((p) => p.priceId)
.map((p) => ({ id: p.id, durationDays: p.durationDays, amountCents: p.amountCents })),
smtpConfigured: Boolean(smtp.host && smtp.from),
eventsFile: EVENTS_FILE,
fulfillmentFile: FULFILLMENT_STORE,
lookupTtlMs: LOOKUP_TTL_MS,
});
});
}