Files
dashcaddy/dashcaddy-api/routes/billing.js
T
Hermes 9b9711bf24
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-057: close checkout-to-license contract drift (grade B)
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.
2026-08-04 14:18:49 -07:00

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;
};