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.
This commit is contained in:
@@ -16,8 +16,16 @@ const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Master secret file — lives only on admin machine, NEVER shipped
|
||||
const SECRET_FILE = path.join(__dirname, '.license-secret');
|
||||
// Master secret file — lives only on admin machine, NEVER shipped.
|
||||
// Default is `path.join(__dirname, '.license-secret')`. The path is
|
||||
// overridable via the `LICENSE_SECRET_FILE` env var so the CLI can be
|
||||
// driven from CI / isolated test environments without polluting the
|
||||
// source directory (mirrors the `LICENSE_COUNTER_FILE` override pattern).
|
||||
// The Stripe bridge uses the same env var to point at its own secret file
|
||||
// on the bridge host.
|
||||
function _defaultSecretFile() {
|
||||
return process.env.LICENSE_SECRET_FILE || path.join(__dirname, '.license-secret');
|
||||
}
|
||||
|
||||
// License code format: DC-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE
|
||||
// Encodes: version(4bit) + duration_days(12bit) + code_id(32bit) + created_ts(32bit) + hmac(40bit)
|
||||
@@ -61,12 +69,13 @@ function base32Decode(str) {
|
||||
}
|
||||
|
||||
function getSecret() {
|
||||
if (!fs.existsSync(SECRET_FILE)) {
|
||||
console.error('No master secret found at', SECRET_FILE);
|
||||
const file = _defaultSecretFile();
|
||||
if (!fs.existsSync(file)) {
|
||||
console.error('No master secret found at', file);
|
||||
console.error('Run with --init-secret first.');
|
||||
process.exit(1);
|
||||
}
|
||||
return fs.readFileSync(SECRET_FILE, 'utf8').trim();
|
||||
return fs.readFileSync(file, 'utf8').trim();
|
||||
}
|
||||
|
||||
// Counter location: the default is `path.join(__dirname, '.license-counter')`.
|
||||
@@ -239,7 +248,7 @@ function generateCodes(opts) {
|
||||
* @throws If the file is missing or unreadable.
|
||||
*/
|
||||
function loadSecret(overridePath) {
|
||||
const file = overridePath || SECRET_FILE;
|
||||
const file = overridePath || _defaultSecretFile();
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(`Master secret file not found at ${file}. Run --init-secret first.`);
|
||||
}
|
||||
@@ -247,14 +256,15 @@ function loadSecret(overridePath) {
|
||||
}
|
||||
|
||||
function initSecret() {
|
||||
if (fs.existsSync(SECRET_FILE)) {
|
||||
console.error('Master secret already exists at', SECRET_FILE);
|
||||
const file = _defaultSecretFile();
|
||||
if (fs.existsSync(file)) {
|
||||
console.error('Master secret already exists at', file);
|
||||
console.error('Delete it first if you want to regenerate (WARNING: invalidates all existing codes).');
|
||||
process.exit(1);
|
||||
}
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 });
|
||||
console.log('Master secret generated and saved to', SECRET_FILE);
|
||||
fs.writeFileSync(file, secret, { mode: 0o600 });
|
||||
console.log('Master secret generated and saved to', file);
|
||||
console.log('KEEP THIS FILE SAFE. It is needed to generate and validate all license codes.');
|
||||
console.log('DO NOT ship this file with the product.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user