#!/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=,v1= * * * 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 { atomicWriteJSON } = require('../src/utils/atomic-write'); const catalog = require('../src/billing/catalog'); const invoice = require('../src/billing/invoice'); 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=,v1=[,v1=]* * * 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) { // Canonical atomic writer (DC-099/DC-104): exclusive-create tmp + fsync + // rename + parent-dir fsync, 0600. Replaces the private tmp+rename copy — // a torn stripe-events.json silently drops event-ids, which makes a // Stripe retry re-run delivery (duplicate license email / duplicate key). atomicWriteJSON(EVENTS_FILE, state, { mode: 0o600 }); } 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 + invoice 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. * * The email is multipart/alternative (text + HTML, matching the same * branded content) with a branded PDF invoice attached. Rendered by * src/billing/invoice.js — see that module for the security/escape rules. * * Returns { delivered: bool, via: 'smtp' | 'dev-console' }. */ async function deliverCode({ to, code, durationDays, eventId, productId, customerName, sessionId, amountCents, currency, supportUrl, issuedAt }) { const product = catalog.getProduct(productId); if (!product) { // Should never happen — catalog resolution happens upstream. Defensive // throw so the operator notices misconfiguration instead of silently // sending a half-blank invoice. throw new Error(`deliverCode: unknown productId ${productId}`); } const invoiceInput = { email: to, customerName: customerName || '', code, durationDays, productLabel: product.label, productId: product.id, amountCents: amountCents != null ? amountCents : product.amountCents, currency: currency || 'USD', eventId, sessionId: sessionId || '', supportUrl: supportUrl || 'https://dashcaddy.net', issuedAt: issuedAt || new Date().toISOString(), }; const { subject, html } = invoice.renderLicenseEmailHtml(invoiceInput); const text = invoice.renderLicenseEmailText(invoiceInput); // PDF generation can throw on poison-pill inputs that survive sanitization // (e.g. lone surrogates in customer name that PDFKit's WinAnsi encoder // rejects, or malformed `issuedAt` after the bridge passes a bad value). // We try the PDF and degrade gracefully: send text+HTML WITHOUT the PDF // attachment so the customer still gets the license + invoice link rather // than nothing. The fulfillment record still marks `delivered` — the // license was persisted upstream, so lookup always works regardless. let pdfBuffer = null; let pdfError = null; try { pdfBuffer = await invoice.renderInvoicePdf(invoiceInput); } catch (err) { pdfError = err; log('warn', 'pdf-render-failed-degrading-to-text-only', { eventId, sessionId, error: err.message, }); } // Sanitize the PDF filename — event id has Stripe's prefix and underscores // which are safe, but we constrain the charset anyway for attachment // parsers that may be picky. const safeInvoiceNumber = invoice.sanitizeFilenameSegment( invoice.generateInvoiceNumber(eventId), 'invoice' ); const attachmentFilename = `DashCaddy-Pro-Invoice-${safeInvoiceNumber}.pdf`; 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, invoiceNumber: safeInvoiceNumber, pdfBytes: pdfBuffer ? pdfBuffer.length : null, pdfError: pdfError && pdfError.message, }); 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' }, }); const mailArgs = { from: smtp.from, to, subject, text, html, }; if (pdfBuffer) { mailArgs.attachments = [ { filename: attachmentFilename, content: pdfBuffer, contentType: 'application/pdf', encoding: 'base64', }, ]; } await transporter.sendMail(mailArgs); 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' } }; // Stripe sends the customer's name on `customer_details.name` for hosted // Checkout (sometimes blank — they may have entered only an email). We // pass it through to the invoice renderer for the "Hi " greeting // and the bill-to block. const customerName = (session.customer_details && session.customer_details.name) || ''; // Amount comes from the session's line_items (Stripe Checkout totals). // Older sessions may not have line_items expanded — fall back to the // session amount_total, then to the catalog amount so the invoice is // never blank. The invoice is a financial document — we ALWAYS render // the catalog's canonical amount when Stripe doesn't tell us a different // one, because the catalog is the single source of truth for DashCaddy's // pricing. This prevents Stripe Checkout config drift (e.g. a test // coupon, a multi-seat plan we don't support) from producing invoices // that don't match the user's actual entitlement. let amountCents = null; let currency = (session.currency || 'USD').toString().toUpperCase(); const lineItems = session.line_items && session.line_items.data; if (Array.isArray(lineItems) && lineItems.length > 0) { // Sum ALL line items, not just lineItems[0]. The previous version // silently dropped quantity > 1 or multi-item carts, producing // invoices whose total didn't match the Stripe charge. session.amount_total // does this automatically too, but reading line items ourselves lets us // log a warning when Stripe's amount_total disagrees with the line-item // sum (indicative of a Stripe-side bug or tampering). const sumFromLineItems = lineItems.reduce((acc, item) => { if (item && item.amount_total != null) return acc + item.amount_total; if (item && item.price && item.price.unit_amount != null) return acc + item.price.unit_amount; return acc; }, 0); if (sumFromLineItems > 0) amountCents = sumFromLineItems; if (lineItems[0] && lineItems[0].currency) currency = lineItems[0].currency.toUpperCase(); } if (amountCents == null && session.amount_total != null) { amountCents = session.amount_total; } // Final fallback: catalog's canonical price for this product. This is // the single source of truth — if Stripe sends 0 or NaN, we render the // catalog price rather than a $0.00 invoice for a real charge. if (amountCents == null || !Number.isFinite(amountCents) || amountCents <= 0) { log('warn', 'amount-fell-back-to-catalog', { eventId: id, sessionId, stripeAmountCents: amountCents, catalogAmountCents: product.amountCents, }); amountCents = product.amountCents; } // Currency must always be a 3-letter ISO code; sanitize otherwise. if (!/^[A-Z]{3}$/.test(currency)) { log('warn', 'invalid-currency-from-stripe', { eventId: id, sessionId, stripeCurrency: currency }); currency = 'USD'; } 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 } }; } // Layer-2 delivery idempotency: if the claim was NOT successful AND the // record is already `delivered`, an earlier event (or this same event via // layer-1) already produced an invoice email. Stripe may legitimately send // `checkout.session.completed` AND `checkout.session.async_payment_succeeded` // for the same Checkout Session (delayed-payment methods). Without this // guard the customer receives TWO invoice emails with TWO different // invoice numbers for one charge. Ack 200 so Stripe stops retrying. if (deliveryClaim.claimed === false && deliveryClaim.record && deliveryClaim.record.status === 'delivered') { log('info', 'delivery-already-completed', { eventId: id, sessionId, ownerEventId: deliveryClaim.record.eventId, }); return { status: 200, body: { delivered: true, deduplicated: true, codeId: deliveryClaim.record.codeId, productId: deliveryClaim.record.productId, durationDays: deliveryClaim.record.durationDays, deliveredVia: deliveryClaim.record.deliveredVia || 'smtp', }, }; } let delivery; try { delivery = await deliverCode({ to: email, code, durationDays, eventId: id, productId: product.id, customerName, sessionId, amountCents, currency, // Pin issuedAt to the ORIGINAL claim's createdAt so a retry days later // renders the same "Issued" date. Falls back to now() for first-time. issuedAt: claim.record && claim.record.createdAt, supportUrl: process.env.DASHCADDY_SUPPORT_URL || 'https://dashcaddy.net', }); } 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: }. */ 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/ — 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/ 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, }); }); }