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.
764 lines
31 KiB
JavaScript
764 lines
31 KiB
JavaScript
#!/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,
|
|
});
|
|
});
|
|
}
|