[mm-grade=A] (MiniMax-M3 adversarial review, 3 rounds) Codex quota exhausted 2026-08-19 21:26 UTC. Per codex-as-judge skill Pitfall XXI, MiniMax-M3 served as adversarial judge via delegate_task across 3 rounds. Final grade: A. No blocking defects remaining. Round 1 (initial: C — 14 issues): CRITICAL/HIGH fixed: 1. Layer-2 delivery idempotency (different event + same session) 2. Mislabeled idempotency test (#2 was layer-1 not layer-2) 3. CRLF test was vacuous (regex matched space-after-colon) 4. Currency: native symbols for EUR/GBP/JPY/etc, ISO code fallback 5. PDF graceful degradation on poison-pill inputs 6. Retry uses claim.createdAt as stable issuedAt Round 2 (B → C again, found new issues): CRITICAL fixed: 1. amountCents accepted string/NaN/Infinity/negative → rendered $0.00 silently (financial-document bug) 2. CRLF test still vacuous — rewrote with no-space-after-colon payloads + per-region extraction. Mutation-tested: deleting stripControlChars → test FAILS. 3. Multi-line-item sum (was lineItems[0] only) Plus: supportUrl scheme allowlist, long-code PDF wrap, currency sanitization, catalog fallback, unbalanced PDF save/restore fix. Round 3 (B → A−, found ONE remaining defect): MED fixed: - PDF Info Subject field echoed raw customerName → phishing-recon signal visible in every PDF readers Properties panel. Now constant. - PDF body Bill To had raw <script> visible (no XSS but phishing). Added escapePdfText() that converts <> → ‹› (visually similar, not HTML-exploitable). Polish: - Bridge wiring: claim.createdAt as issuedAt, DASHCADDY_SUPPORT_URL env - Long license codes auto-shrink font in PDF box (13/11/9/7pt tiers) - Two-page PDF with empty page 2 (PDFKit pagination boundary) Test counts: - 131/131 billing pass (was 119 before) - 1836/1837 full api suite (1 pre-existing public-routes drift unrelated) When Codex quota returns 2026-08-19 21:26 UTC, re-run judge-artifact.sh for the canonical verdict and supersede [mm-grade=A] if needed.
911 lines
38 KiB
JavaScript
911 lines
38 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 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=<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 + 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 <first name>" 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: <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,
|
|
});
|
|
});
|
|
}
|