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:
@@ -0,0 +1,179 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Durable Stripe fulfillment state.
|
||||
*
|
||||
* The bridge and the API share this file through the host data mount. A
|
||||
* generated license is persisted before email delivery so a webhook retry can
|
||||
* resend the same key instead of minting a second valid key.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
const DELIVERY_LEASE_MS = 5 * 60 * 1000;
|
||||
|
||||
function createFulfillmentStore(options = {}) {
|
||||
const filePath = options.filePath
|
||||
|| process.env.STRIPE_FULFILLMENT_FILE
|
||||
|| path.join(platformPaths.dataDir, 'stripe-fulfillments.json');
|
||||
let queue = Promise.resolve();
|
||||
|
||||
function emptyState() {
|
||||
return { version: 1, byEventId: {}, bySessionId: {} };
|
||||
}
|
||||
|
||||
function readState() {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return emptyState();
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
if (!parsed || typeof parsed !== 'object'
|
||||
|| !parsed.byEventId || typeof parsed.byEventId !== 'object'
|
||||
|| !parsed.bySessionId || typeof parsed.bySessionId !== 'object') {
|
||||
throw new Error('fulfillment state has an invalid shape');
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return emptyState();
|
||||
throw new Error(`Stripe fulfillment state unavailable: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(state) {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${filePath}.tmp.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
|
||||
try {
|
||||
fs.renameSync(tmp, filePath);
|
||||
} catch (error) {
|
||||
try { fs.unlinkSync(tmp); } catch (_) { /* best effort */ }
|
||||
throw error;
|
||||
}
|
||||
try { fs.chmodSync(filePath, 0o600); } catch (_) { /* best effort */ }
|
||||
}
|
||||
|
||||
function mutate(mutator) {
|
||||
const run = queue.then(async () => {
|
||||
const state = readState();
|
||||
const result = await mutator(state);
|
||||
if (result && result.changed) writeState(state);
|
||||
return result;
|
||||
});
|
||||
queue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
function readBySession(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
const record = readState().bySessionId[sessionId];
|
||||
return record ? { ...record } : null;
|
||||
}
|
||||
|
||||
function readByEvent(eventId) {
|
||||
if (!eventId) return null;
|
||||
const record = readState().byEventId[eventId];
|
||||
return record ? { ...record } : null;
|
||||
}
|
||||
|
||||
async function claim({ eventId, sessionId, productId, durationDays, email }) {
|
||||
if (!eventId || !sessionId) throw new Error('eventId and sessionId are required');
|
||||
return mutate((state) => {
|
||||
const existing = state.bySessionId[sessionId] || state.byEventId[eventId];
|
||||
const now = Date.now();
|
||||
if (existing) {
|
||||
if (existing.status === 'generating' && existing.leaseUntil > now && existing.claimToken !== eventId) {
|
||||
return { changed: false, claimed: false, busy: true, record: { ...existing } };
|
||||
}
|
||||
if (existing.status === 'generating' && existing.leaseUntil <= now) {
|
||||
existing.claimToken = eventId;
|
||||
existing.leaseUntil = now + DELIVERY_LEASE_MS;
|
||||
state.byEventId[eventId] = existing;
|
||||
return { changed: true, claimed: true, busy: false, record: { ...existing } };
|
||||
}
|
||||
return { changed: false, claimed: false, busy: false, record: { ...existing } };
|
||||
}
|
||||
const record = {
|
||||
eventId, sessionId, productId, durationDays, email,
|
||||
status: 'generating', claimToken: eventId, leaseUntil: now + DELIVERY_LEASE_MS,
|
||||
createdAt: new Date(now).toISOString(), updatedAt: new Date(now).toISOString(),
|
||||
};
|
||||
state.byEventId[eventId] = record;
|
||||
state.bySessionId[sessionId] = record;
|
||||
return { changed: true, claimed: true, busy: false, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
async function saveLicense({ eventId, sessionId, code, codeId }) {
|
||||
return mutate((state) => {
|
||||
const record = state.bySessionId[sessionId] || state.byEventId[eventId];
|
||||
if (!record || record.claimToken !== eventId) return { changed: false, saved: false, record: record ? { ...record } : null };
|
||||
record.code = code;
|
||||
record.codeId = codeId;
|
||||
record.status = 'pending_email';
|
||||
record.leaseUntil = 0;
|
||||
record.updatedAt = new Date().toISOString();
|
||||
state.byEventId[record.eventId] = record;
|
||||
state.byEventId[eventId] = record;
|
||||
state.bySessionId[record.sessionId] = record;
|
||||
return { changed: true, saved: true, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
async function claimDelivery({ sessionId, ownerToken }) {
|
||||
return mutate((state) => {
|
||||
const record = state.bySessionId[sessionId];
|
||||
if (!record || !record.code) return { changed: false, claimed: false, record: record ? { ...record } : null };
|
||||
const now = Date.now();
|
||||
if (record.status === 'delivered') return { changed: false, claimed: false, record: { ...record } };
|
||||
if (record.status === 'delivering' && record.leaseUntil > now && record.leaseOwner !== ownerToken) {
|
||||
return { changed: false, claimed: false, busy: true, record: { ...record } };
|
||||
}
|
||||
record.status = 'delivering';
|
||||
record.leaseOwner = ownerToken;
|
||||
record.leaseUntil = now + DELIVERY_LEASE_MS;
|
||||
record.updatedAt = new Date(now).toISOString();
|
||||
state.byEventId[record.eventId] = record;
|
||||
state.bySessionId[sessionId] = record;
|
||||
return { changed: true, claimed: true, busy: false, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
async function markDelivered({ sessionId, ownerToken, deliveredVia }) {
|
||||
return mutate((state) => {
|
||||
const record = state.bySessionId[sessionId];
|
||||
if (!record || record.leaseOwner !== ownerToken) return { changed: false, saved: false };
|
||||
record.status = 'delivered';
|
||||
record.deliveredVia = deliveredVia;
|
||||
record.deliveredAt = new Date().toISOString();
|
||||
record.lastError = null;
|
||||
record.leaseUntil = 0;
|
||||
record.leaseOwner = null;
|
||||
record.updatedAt = new Date().toISOString();
|
||||
state.byEventId[record.eventId] = record;
|
||||
state.bySessionId[sessionId] = record;
|
||||
return { changed: true, saved: true, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
async function markDeliveryFailed({ sessionId, ownerToken, error }) {
|
||||
return mutate((state) => {
|
||||
const record = state.bySessionId[sessionId];
|
||||
if (!record || record.leaseOwner !== ownerToken) return { changed: false, saved: false };
|
||||
record.status = 'pending_email';
|
||||
record.lastError = String(error || 'email delivery failed').slice(0, 500);
|
||||
record.leaseUntil = 0;
|
||||
record.leaseOwner = null;
|
||||
record.updatedAt = new Date().toISOString();
|
||||
state.byEventId[record.eventId] = record;
|
||||
state.bySessionId[sessionId] = record;
|
||||
return { changed: true, saved: true, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
return { filePath, readBySession, readByEvent, claim, saveLicense, claimDelivery, markDelivered, markDeliveryFailed };
|
||||
}
|
||||
|
||||
module.exports = { createFulfillmentStore, DELIVERY_LEASE_MS };
|
||||
Reference in New Issue
Block a user