'use strict'; /** * DC-055 + DC-057 Billing routes — `/api/v1/billing/*` * * Two public endpoints: * * POST /api/v1/billing/checkout * Body: { productId: 'pro-30d'|'pro-90d'|'pro-180d'|'pro-365d', customerEmail?: string } * Returns: { id, url } — url is the Stripe-hosted Checkout page. * Auth: PUBLIC (customer hasn't paid yet → no session). CSRF-exempt. * * GET /api/v1/billing/lookup/:sessionId * Returns: { status: 'not_found'|'expired'|'processing'|'pending_email'|'delivered', code?, codeId?, durationDays?, productId?, deliveredVia? } * Auth: PUBLIC. CSRF-exempt. * * The lookup serves the persisted license in BOTH `delivered` AND * `pending_email` states. This is the documented SMTP-failure recovery * path (the customer pastes their key even if email failed). * * Security model: * - sessionId is a bearer-style secret returned by Stripe ONLY to the * customer who completed payment (single-use, expires after 24h). * - The endpoint enforces a 24h TTL (LOOKUP_TTL_MS) — after that, * 404 even with a valid sessionId. * - Cache-Control: no-store on all responses. * - Rate-limited via the general limiter (10/min/IP). * * The inbound webhook side lives in scripts/stripe-license-bridge.js * (DC-054 + DC-057). That runs as its own process on port 3010 so the * merchant webhook secret never enters the API host's process tree. * The bridge writes to the SAME fulfillment-store file the lookup endpoint * reads (platformPaths.dataDir + 'stripe-fulfillments.json'), so the API * sees the license as soon as the bridge saves it. */ const express = require('express'); const { ok, errorResponse } = require('../src/utils/responses'); const { ValidationError } = require('../src/utilities/errors'); const { createCheckoutSession } = require('../src/billing/stripe-client'); const { createFulfillmentStore } = require('../src/billing/fulfillment-store'); // One fulfillment-store instance per process. Reads from the same file the // bridge writes to — IPC via the bind-mounted data dir. Override path via // STRIPE_BRIDGE_FULFILLMENT_STORE_FILE (the bridge reads the same env var // at startup) — both processes target the same file. In tests we point // at a tmp dir. const fulfillmentStore = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE, }); const LOOKUP_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours — matches Stripe's // default Checkout session expiry. /** * Compute the public origin to embed in Stripe Checkout success/cancel * URLs. * * SECURITY: the success_url is what Stripe redirects the customer's * browser to after payment. If we let the request's Host header * influence it unchecked, a header-injection attacker could redirect * customers to their own origin — and the session_id in the URL is * the bearer token for /api/v1/billing/lookup/:sessionId (the customer * would then leak their own license to the attacker). So we derive * the origin only from TRUSTED SOURCES: * * 1. `STRIPE_PUBLIC_ORIGIN` env var (preferred — operator-declared) * 2. `STRIPE_ALLOWED_HOSTS` allowlist + request Host header * (fallback for operators who don't set the env var) * 3. `undefined` → Stripe Checkout falls back to its default * success/cancel URL behavior (still safe; just loses the * success-page reveal flow). * * We also enforce scheme allowlist (https only by default; http only * for explicit dev mode) to prevent javascript:/file:/data: smuggling. */ function _resolvePublicOrigin(req) { // 1. Explicit env-var override (canonical deployment shape). if (process.env.STRIPE_PUBLIC_ORIGIN) { const raw = process.env.STRIPE_PUBLIC_ORIGIN.trim(); try { const u = new URL(raw); if (u.protocol === 'https:' || (u.protocol === 'http:' && process.env.NODE_ENV !== 'production')) { return `${u.protocol}//${u.host}`; } } catch (_) { /* fall through to header-based resolution */ } } // 2. Header-based fallback, gated by STRIPE_ALLOWED_HOSTS allowlist. const allowedHosts = (process.env.STRIPE_ALLOWED_HOSTS || '') .split(',').map((h) => h.trim().toLowerCase()).filter(Boolean); if (allowedHosts.length === 0) return undefined; const host = (req.get('x-forwarded-host') || req.get('host') || '').toLowerCase(); // Strip :port for comparison; port is added back when building the URL. const hostNoPort = host.split(':')[0]; if (!hostNoPort) return undefined; if (!allowedHosts.includes(hostNoPort) && !allowedHosts.includes(host)) return undefined; const rawProto = (req.get('x-forwarded-proto') || req.protocol || 'https').toLowerCase(); const proto = rawProto === 'http' && process.env.NODE_ENV !== 'production' ? 'http' : 'https'; return `${proto}://${host}`; } /** * Billing routes factory * @param {Object} deps * @param {Function} deps.asyncHandler - async route handler wrapper * @returns {express.Router} */ module.exports = function({ asyncHandler }) { const router = express.Router(); /** * POST /api/v1/billing/checkout * * Body: { productId: 'pro-30d'|'pro-90d'|'pro-180d'|'pro-365d', customerEmail?: string } * Returns: { id, url } * * The customer is redirected to `url`. On success Stripe redirects to * {origin}/billing/success?session_id={CHECKOUT_SESSION_ID}. The * webhook bridge (scripts/stripe-license-bridge.js) generates the * license on `checkout.session.completed`, persists it to the * fulfillment store, emails it, and the success page polls the lookup * endpoint below to reveal it. * * SECURITY: The success_url is embedded in the Stripe Checkout Session * and shown to the customer in their browser. If an attacker can * control the `Host` / `X-Forwarded-Host` header, they can poison * Stripe's redirect to their own origin — and the customer's session * ID (which is the bearer token for /api/v1/billing/lookup/:sessionId) * lands in the attacker's URL bar. The lookup endpoint would then * serve the license to the attacker's browser. * * To prevent this, the API derives the origin from an explicit * `STRIPE_PUBLIC_ORIGIN` env var when set (the canonical deployment * shape). If unset, we fall back to the request's Host header, BUT * only when the Host is in the explicit `STRIPE_ALLOWED_HOSTS` allowlist * (comma-separated). This means a fresh operator MUST either set the * env var OR explicitly allowlist their hostname before checkout can * create sessions — a hostile header alone is not enough. */ router.post('/checkout', asyncHandler(async (req, res) => { const { productId, customerEmail } = req.body || {}; const origin = _resolvePublicOrigin(req); try { if (!productId) throw new ValidationError('productId is required', 'productId'); const session = await createCheckoutSession({ productId, customerEmail, origin }); ok(res, { data: session }); } catch (err) { if (err.code === 'STRIPE_NOT_CONFIGURED' || err.code === 'INVALID_PRODUCT_ID') { return errorResponse(res, err.statusCode || 500, err.message); } if (err.code === 'DC-400' || err instanceof ValidationError) { return errorResponse(res, err.statusCode || 400, err.message); } // Stripe SDK throws Stripe-specific errors; surface message but don't leak // Stripe's full response (may include internal IDs we don't want exposed). if (err.type && err.type.startsWith('Stripe')) { return errorResponse(res, 502, 'Payment provider error. Please try again.'); } throw err; } }, 'billing-checkout')); /** * GET /api/v1/billing/lookup/:sessionId * * Returns the fulfillment state for a Stripe Checkout session. The * success page polls this every 1.5s until `delivered` or `pending_email`. * * - 200 + { status: 'processing', durationDays } — license being generated * - 200 + { status: 'pending_email', code, codeId, durationDays, productId, ... } * — license persisted (SMTP failure recovery) * - 200 + { status: 'delivered', code, codeId, durationDays, productId, deliveredVia } * — license delivered by email * - 404 + { status: 'not_found' } — no payment record for this sessionId * - 404 + { status: 'expired' } — record exists but past the 24h TTL * * Cache-Control: no-store. CSRF-exempt. PUBLIC_ROUTES allowlist. */ router.get('/lookup/:sessionId', asyncHandler(async (req, res) => { const { sessionId } = req.params; res.set('Cache-Control', 'no-store'); if (!sessionId || typeof sessionId !== 'string' || sessionId.length > 256) { return errorResponse(res, 400, 'invalid sessionId'); } const record = fulfillmentStore.readBySession(sessionId); if (!record) { return errorResponse(res, 404, 'no record for that sessionId'); } const createdAt = record.createdAt ? Date.parse(record.createdAt) : Date.now(); const ageMs = Date.now() - createdAt; if (Number.isFinite(ageMs) && ageMs > LOOKUP_TTL_MS) { return errorResponse(res, 404, 'record past lookup TTL'); } if (record.status === 'generating' || (!record.code && record.status !== 'delivered')) { return ok(res, { data: { status: 'processing', durationDays: record.durationDays, productId: record.productId } }); } if (record.status === 'delivered') { return ok(res, { data: { status: 'delivered', durationDays: record.durationDays, productId: record.productId, code: record.code, codeId: record.codeId, deliveredVia: record.deliveredVia || 'unknown' } }); } // pending_email OR delivering — license is durably persisted. return ok(res, { data: { status: 'pending_email', durationDays: record.durationDays, productId: record.productId, code: record.code, codeId: record.codeId, deliveredVia: record.deliveredVia, lastError: record.lastError, }, }); }, 'billing-lookup')); return router; };