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.
227 lines
9.9 KiB
JavaScript
227 lines
9.9 KiB
JavaScript
'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;
|
|
};
|