DC-057: close checkout-to-license contract drift (grade B)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

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:
Hermes
2026-08-04 14:18:49 -07:00
parent f154f501ff
commit 9b9711bf24
19 changed files with 3399 additions and 26 deletions
+19 -10
View File
@@ -401,59 +401,68 @@ describe('license-keygen: CLI regression', () => {
function _setupSecret() {
fs.writeFileSync(path.join(tmp, '.license-secret'), TEST_SECRET);
return path.join(tmp, '.license-secret');
}
test('omitted --start-id uses the auto-counter path (CLI integration)', () => {
_setupSecret();
const secretFile = _setupSecret();
const counterFile = path.join(tmp, '.license-counter');
// First call: no --start-id, expects counter to be created at 1.
const out1 = _runCli(['--duration', '30', '--count', '1', '--json'], {
LICENSE_COUNTER_FILE: counterFile,
LICENSE_SECRET_FILE: secretFile,
});
const codes1 = JSON.parse(out1.split('Generated')[0]);
expect(codes1).toHaveLength(1);
expect(codes1[0].codeId).toBe(1);
expect(codes1.length).toBe(1);
expect(codes1[0].durationDays).toBe(30);
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('1');
// Second call: counter should auto-increment to 2.
const out2 = _runCli(['--duration', '30', '--count', '1', '--json'], {
LICENSE_COUNTER_FILE: counterFile,
LICENSE_SECRET_FILE: secretFile,
});
const codes2 = JSON.parse(out2.split('Generated')[0]);
expect(codes2[0].codeId).toBe(2);
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('2');
expect(codes2[0].codeId).toBeGreaterThan(codes1[0].codeId);
});
test('--start-id override skips counter file update (CLI integration)', () => {
_setupSecret();
const secretFile = _setupSecret();
const counterFile = path.join(tmp, '.license-counter');
fs.writeFileSync(counterFile, '99');
const out = _runCli(['--duration', '30', '--start-id', '500', '--count', '2', '--json'], {
LICENSE_COUNTER_FILE: counterFile,
LICENSE_SECRET_FILE: secretFile,
});
const codes = JSON.parse(out.split('Generated')[0]);
expect(codes.map(c => c.codeId)).toEqual([500, 501]);
// Counter file untouched.
expect(codes.length).toBe(2);
expect(codes[0].codeId).toBe(500);
expect(codes[1].codeId).toBe(501);
// Counter file remains untouched at '99' (override skips auto-update).
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('99');
});
test('--lifetime and --duration are mutually exclusive (CLI integration)', () => {
_setupSecret();
const secretFile = _setupSecret();
expect(() =>
_runCli(['--duration', '30', '--lifetime', '--count', '1'], {
LICENSE_COUNTER_FILE: path.join(tmp, '.license-counter'),
LICENSE_SECRET_FILE: secretFile,
}),
).toThrow(/mutually exclusive/);
});
test('--tier pro without --duration or --lifetime still requires one of them', () => {
_setupSecret();
const secretFile = _setupSecret();
expect(() =>
_runCli(['--tier', 'pro', '--count', '1'], {
LICENSE_COUNTER_FILE: path.join(tmp, '.license-counter'),
LICENSE_SECRET_FILE: secretFile,
}),
).toThrow(/--duration is required/);
});