From dad8aed733071116a5c1995cb891bce2644af4b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:27:59 +0900 Subject: [PATCH 01/25] test(billing): specify durable Stripe webhook event ledger --- .../unit/stripe-webhook-event-ledger.test.mjs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/unit/stripe-webhook-event-ledger.test.mjs diff --git a/tests/unit/stripe-webhook-event-ledger.test.mjs b/tests/unit/stripe-webhook-event-ledger.test.mjs new file mode 100644 index 00000000..4664f349 --- /dev/null +++ b/tests/unit/stripe-webhook-event-ledger.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; +import { + StripeWebhookLedgerError, + createSqliteStripeWebhookEventRepository, + installStripeWebhookEventSchema, +} from '../../server/stripe_webhook_event_ledger.mjs'; + +const HASH_A = 'a'.repeat(64); +const HASH_B = 'b'.repeat(64); + +function event(overrides = {}) { + return { + id: 'evt_scopeweave_1', + object: 'event', + api_version: '2025-02-24.acacia', + created: 1_787_000_000, + data: { object: { id: 'sub_scopeweave_1', object: 'subscription' } }, + request: { id: 'req_scopeweave_1', idempotency_key: null }, + type: 'customer.subscription.updated', + ...overrides, + }; +} + +function setup(now = () => 1_787_000_100_000) { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON'); + installStripeWebhookEventSchema(database); + const repository = createSqliteStripeWebhookEventRepository(database, { now }); + return { database, repository }; +} + +test('schema is bootstrap-installed, normalized, and does not retain raw payloads', () => { + const { database } = setup(); + installStripeWebhookEventSchema(database); + + const eventColumns = database.prepare("PRAGMA table_info('billing_stripe_webhook_events')").all().map((row) => row.name); + const deliveryColumns = database.prepare("PRAGMA table_info('billing_stripe_webhook_deliveries')").all().map((row) => row.name); + + assert.deepEqual(eventColumns, [ + 'event_id', 'provider_created_at_sec', 'event_type', 'object_id', 'object_type', + 'api_version', 'request_id', 'payload_sha256', 'first_received_at_ms', + ]); + assert.deepEqual(deliveryColumns, [ + 'delivery_id', 'event_id', 'received_at_ms', 'replay_state', 'processing_result', + ]); + assert.equal(eventColumns.some((name) => /raw|payload_json|body/i.test(name)), false); +}); + +test('first verified event stores bounded immutable metadata and one non-replay delivery', () => { + const { database, repository } = setup(); + const result = repository.recordVerifiedEvent({ event: event(), payloadSha256: HASH_A }); + + assert.equal(result.replayed, false); + assert.equal(result.eventId, 'evt_scopeweave_1'); + assert.deepEqual(database.prepare('SELECT * FROM billing_stripe_webhook_events').get(), { + event_id: 'evt_scopeweave_1', + provider_created_at_sec: 1_787_000_000, + event_type: 'customer.subscription.updated', + object_id: 'sub_scopeweave_1', + object_type: 'subscription', + api_version: '2025-02-24.acacia', + request_id: 'req_scopeweave_1', + payload_sha256: HASH_A, + first_received_at_ms: 1_787_000_100_000, + }); + assert.deepEqual(database.prepare('SELECT event_id, replay_state, processing_result FROM billing_stripe_webhook_deliveries').get(), { + event_id: 'evt_scopeweave_1', replay_state: 'first_delivery', processing_result: 'received', + }); +}); + +test('exact duplicate event IDs are idempotent and recorded as replay evidence', () => { + const { database, repository } = setup(); + repository.recordVerifiedEvent({ event: event(), payloadSha256: HASH_A }); + const replay = repository.recordVerifiedEvent({ event: event(), payloadSha256: HASH_A }); + + assert.equal(replay.replayed, true); + assert.equal(database.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_events').get().count, 1); + assert.deepEqual( + database.prepare('SELECT replay_state, processing_result FROM billing_stripe_webhook_deliveries ORDER BY delivery_id').all(), + [ + { replay_state: 'first_delivery', processing_result: 'received' }, + { replay_state: 'duplicate_event', processing_result: 'duplicate_ignored' }, + ], + ); +}); + +test('same event ID with a different verified payload hash fails closed without recording a false replay', () => { + const { database, repository } = setup(); + repository.recordVerifiedEvent({ event: event(), payloadSha256: HASH_A }); + + assert.throws( + () => repository.recordVerifiedEvent({ event: event(), payloadSha256: HASH_B }), + (error) => error instanceof StripeWebhookLedgerError && error.code === 'stripe_webhook_event_conflict' && error.status === 409, + ); + assert.equal(database.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_deliveries').get().count, 1); +}); + +test('malformed provider ordering and object identity metadata fail before persistence', () => { + const { database, repository } = setup(); + const invalid = [ + event({ created: -1 }), + event({ created: 1.5 }), + event({ data: {} }), + event({ data: { object: { id: '', object: 'subscription' } } }), + event({ request: { id: 'x'.repeat(256) } }), + ]; + for (const candidate of invalid) { + assert.throws( + () => repository.recordVerifiedEvent({ event: candidate, payloadSha256: HASH_A }), + (error) => error instanceof StripeWebhookLedgerError && error.code === 'stripe_webhook_event_invalid' && error.status === 400, + ); + } + assert.equal(database.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_events').get().count, 0); +}); From 09956c1521765c357cb858493e11c813aceab9e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:28:22 +0900 Subject: [PATCH 02/25] test(billing): register Stripe webhook event ledger regression --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 151893bc..5827eb28 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/stripe-webhook.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/stripe_webhook.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && npm run test:api", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/stripe_webhook.mjs --include=server/stripe_webhook_event_ledger.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", From 8c7471bb83e7c7a69d2d728736432dc8dcff296e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:29:15 +0900 Subject: [PATCH 03/25] feat(billing): persist verified Stripe webhook event evidence --- server/stripe_webhook_event_ledger.mjs | 216 +++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 server/stripe_webhook_event_ledger.mjs diff --git a/server/stripe_webhook_event_ledger.mjs b/server/stripe_webhook_event_ledger.mjs new file mode 100644 index 00000000..d989afb0 --- /dev/null +++ b/server/stripe_webhook_event_ledger.mjs @@ -0,0 +1,216 @@ +const MAX_EVENT_FIELD_LENGTH = 255; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const SAVEPOINT_NAME = 'billing_stripe_webhook_event_write'; + +/** Stable persistence-boundary error for verified Stripe webhook events. */ +export class StripeWebhookLedgerError extends Error { + /** + * @param {string} code stable machine-readable failure code + * @param {number} status HTTP status suitable for the webhook adapter + */ + constructor(code, status) { + super(code); + this.name = 'StripeWebhookLedgerError'; + this.code = code; + this.status = status; + } +} + +function ledgerError(code, status = 400) { + return new StripeWebhookLedgerError(code, status); +} + +function requiredString(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_EVENT_FIELD_LENGTH) { + throw ledgerError('stripe_webhook_event_invalid'); + } + return value; +} + +function nullableString(value) { + if (value == null) return null; + return requiredString(value); +} + +function safeCreated(value) { + if (!Number.isSafeInteger(value) || value < 0) { + throw ledgerError('stripe_webhook_event_invalid'); + } + return value; +} + +function safeNow(now) { + const value = Number(now()); + if (!Number.isSafeInteger(value) || value < 0) { + throw ledgerError('stripe_webhook_event_invalid'); + } + return value; +} + +function normalizedPayloadHash(value) { + if (typeof value !== 'string' || !SHA256_PATTERN.test(value)) { + throw ledgerError('stripe_webhook_event_invalid'); + } + return value.toLowerCase(); +} + +function normalizedEvent(event) { + if (!event || typeof event !== 'object' || Array.isArray(event)) { + throw ledgerError('stripe_webhook_event_invalid'); + } + const providerObject = event.data?.object; + if (!providerObject || typeof providerObject !== 'object' || Array.isArray(providerObject)) { + throw ledgerError('stripe_webhook_event_invalid'); + } + const requestId = event.request == null ? null : nullableString(event.request?.id); + return { + eventId: requiredString(event.id), + providerCreatedAtSec: safeCreated(event.created), + eventType: requiredString(event.type), + objectId: requiredString(providerObject.id), + objectType: requiredString(providerObject.object), + apiVersion: nullableString(event.api_version), + requestId, + }; +} + +function withSavepoint(database, operation) { + database.exec(`SAVEPOINT ${SAVEPOINT_NAME}`); + try { + const result = operation(); + database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); + return result; + } catch (error) { + try { + database.exec(`ROLLBACK TO SAVEPOINT ${SAVEPOINT_NAME}`); + } finally { + database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); + } + throw error; + } +} + +/** + * Install normalized verified-event and delivery-evidence relations at bootstrap. + * + * Event facts are stored once by immutable Stripe event ID. Delivery attempts are + * a separate one-to-many relation so retries remain auditable without duplicating + * event metadata or retaining the signed raw JSON body. + * + * @param {import('node:sqlite').DatabaseSync} database open SQLite database + * @returns {void} + */ +export function installStripeWebhookEventSchema(database) { + database.exec(` + CREATE TABLE IF NOT EXISTS billing_stripe_webhook_events ( + event_id TEXT PRIMARY KEY CHECK(length(event_id) BETWEEN 1 AND ${MAX_EVENT_FIELD_LENGTH}), + provider_created_at_sec INTEGER NOT NULL CHECK(provider_created_at_sec >= 0), + event_type TEXT NOT NULL CHECK(length(event_type) BETWEEN 1 AND ${MAX_EVENT_FIELD_LENGTH}), + object_id TEXT NOT NULL CHECK(length(object_id) BETWEEN 1 AND ${MAX_EVENT_FIELD_LENGTH}), + object_type TEXT NOT NULL CHECK(length(object_type) BETWEEN 1 AND ${MAX_EVENT_FIELD_LENGTH}), + api_version TEXT CHECK(api_version IS NULL OR length(api_version) BETWEEN 1 AND ${MAX_EVENT_FIELD_LENGTH}), + request_id TEXT CHECK(request_id IS NULL OR length(request_id) BETWEEN 1 AND ${MAX_EVENT_FIELD_LENGTH}), + payload_sha256 TEXT NOT NULL CHECK(length(payload_sha256) = 64), + first_received_at_ms INTEGER NOT NULL CHECK(first_received_at_ms >= 0) + ); + CREATE INDEX IF NOT EXISTS billing_stripe_webhook_object_events + ON billing_stripe_webhook_events(object_type, object_id, provider_created_at_sec); + + CREATE TABLE IF NOT EXISTS billing_stripe_webhook_deliveries ( + delivery_id INTEGER PRIMARY KEY, + event_id TEXT NOT NULL REFERENCES billing_stripe_webhook_events(event_id) ON DELETE CASCADE, + received_at_ms INTEGER NOT NULL CHECK(received_at_ms >= 0), + replay_state TEXT NOT NULL CHECK(replay_state IN ('first_delivery','duplicate_event')), + processing_result TEXT NOT NULL CHECK(processing_result IN ('received','duplicate_ignored')) + ); + CREATE INDEX IF NOT EXISTS billing_stripe_webhook_event_deliveries + ON billing_stripe_webhook_deliveries(event_id, delivery_id); + `); +} + +/** + * Create a persistence port for cryptographically verified Stripe event evidence. + * + * The constructor never creates tables; call {@link installStripeWebhookEventSchema} + * during database bootstrap. A repeated exact event ID plus payload hash is + * idempotently acknowledged and recorded as replay evidence. Reusing an event ID + * with different signed bytes fails closed rather than overwriting immutable facts. + * + * @param {import('node:sqlite').DatabaseSync} database bootstrapped SQLite database + * @param {object} [dependencies] deterministic test seams + * @param {() => number} [dependencies.now] wall-clock milliseconds + * @returns {{recordVerifiedEvent(input: {event: Record, payloadSha256: string}): {eventId: string, replayed: boolean, deliveryId: number}}} + */ +export function createSqliteStripeWebhookEventRepository(database, { now = Date.now } = {}) { + if (!database || typeof database.prepare !== 'function' || typeof database.exec !== 'function') { + throw new TypeError('database must provide SQLite prepare/exec operations'); + } + if (typeof now !== 'function') throw new TypeError('now must be a function'); + + const selectEvent = database.prepare(` + SELECT payload_sha256 FROM billing_stripe_webhook_events WHERE event_id = ? + `); + const insertEvent = database.prepare(` + INSERT INTO billing_stripe_webhook_events( + event_id, provider_created_at_sec, event_type, object_id, object_type, + api_version, request_id, payload_sha256, first_received_at_ms + ) VALUES(?,?,?,?,?,?,?,?,?) + `); + const insertDelivery = database.prepare(` + INSERT INTO billing_stripe_webhook_deliveries( + event_id, received_at_ms, replay_state, processing_result + ) VALUES(?,?,?,?) + `); + + return { + /** Persist one verified delivery and classify exact event-ID replay. */ + recordVerifiedEvent({ event, payloadSha256 }) { + const normalized = normalizedEvent(event); + const hash = normalizedPayloadHash(payloadSha256); + const receivedAtMs = safeNow(now); + + return withSavepoint(database, () => { + const existing = selectEvent.get(normalized.eventId); + if (existing) { + if (existing.payload_sha256 !== hash) { + throw ledgerError('stripe_webhook_event_conflict', 409); + } + const delivery = insertDelivery.run( + normalized.eventId, + receivedAtMs, + 'duplicate_event', + 'duplicate_ignored', + ); + return { + eventId: normalized.eventId, + replayed: true, + deliveryId: Number(delivery.lastInsertRowid), + }; + } + + insertEvent.run( + normalized.eventId, + normalized.providerCreatedAtSec, + normalized.eventType, + normalized.objectId, + normalized.objectType, + normalized.apiVersion, + normalized.requestId, + hash, + receivedAtMs, + ); + const delivery = insertDelivery.run( + normalized.eventId, + receivedAtMs, + 'first_delivery', + 'received', + ); + return { + eventId: normalized.eventId, + replayed: false, + deliveryId: Number(delivery.lastInsertRowid), + }; + }); + }, + }; +} From acee4a658de8132fc3b54dab13631b65f88cd079 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:29:52 +0900 Subject: [PATCH 04/25] test(api): require durable Stripe webhook replay evidence --- tests/api/stripe-webhook.test.mjs | 44 ++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/tests/api/stripe-webhook.test.mjs b/tests/api/stripe-webhook.test.mjs index 870379d7..7e35efca 100644 --- a/tests/api/stripe-webhook.test.mjs +++ b/tests/api/stripe-webhook.test.mjs @@ -11,6 +11,7 @@ process.env.STRIPE_PRICE_ID = 'price_scopeweave_webhook'; process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_api_webhook_secret'; const { app } = await import('../../server/app.mjs?stripe-webhook-api-test=1'); +const { db } = await import('../../server/db.mjs'); const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET; const jsonHeaders = { 'content-type': 'application/json' }; @@ -55,10 +56,15 @@ async function currentPlan(token) { function checkoutCompletedBody(orgId) { return JSON.stringify({ id: `evt_checkout_${orgId}`, + object: 'event', + api_version: '2025-02-24.acacia', + created: Math.floor(Date.now() / 1000), type: 'checkout.session.completed', + request: { id: `req_checkout_${orgId}`, idempotency_key: null }, data: { object: { id: `cs_test_${orgId}`, + object: 'checkout.session', client_reference_id: String(orgId), metadata: { orgId: String(orgId) }, }, @@ -83,7 +89,7 @@ test('unsigned Stripe webhook cannot upgrade an organization', async () => { assert.equal(await currentPlan(token), 'free'); }); -test('verified webhook is acknowledged but does not grant entitlement before durable reconciliation', async () => { +test('verified webhook is durably recorded but does not grant entitlement before reconciliation', async () => { const { token, orgId } = await signupAndOrg(); const body = checkoutCompletedBody(orgId); const response = await app.request('https://scopeweave.example/api/stripe/webhook', { @@ -96,19 +102,48 @@ test('verified webhook is acknowledged but does not grant entitlement before dur }); assert.equal(response.status, 200); - assert.deepEqual(await response.json(), { received: true }); + assert.deepEqual(await response.json(), { received: true, replayed: false }); assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_events').get().count, 1); + assert.deepEqual( + db.prepare('SELECT replay_state, processing_result FROM billing_stripe_webhook_deliveries ORDER BY delivery_id DESC LIMIT 1').get(), + { replay_state: 'first_delivery', processing_result: 'received' }, + ); assert.equal( await currentPlan(token), 'free', - 'authenticated delivery alone cannot bypass durable duplicate/order/provider-state reconciliation', + 'authenticated delivery alone cannot bypass authoritative provider-state reconciliation', ); }); -test('stale signed delivery and raw-body mutation fail before entitlement state changes', async () => { +test('exact duplicate verified event is acknowledged idempotently and retained as replay evidence', async () => { + const { token, orgId } = await signupAndOrg(); + const body = checkoutCompletedBody(orgId); + const headers = { ...jsonHeaders, 'stripe-signature': signatureHeader(body) }; + + let response = await app.request('https://scopeweave.example/api/stripe/webhook', { method: 'POST', headers, body }); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { received: true, replayed: false }); + + response = await app.request('https://scopeweave.example/api/stripe/webhook', { method: 'POST', headers, body }); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { received: true, replayed: true }); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_events WHERE event_id = ?').get(`evt_checkout_${orgId}`).count, 1); + assert.deepEqual( + db.prepare('SELECT replay_state, processing_result FROM billing_stripe_webhook_deliveries WHERE event_id = ? ORDER BY delivery_id').all(`evt_checkout_${orgId}`), + [ + { replay_state: 'first_delivery', processing_result: 'received' }, + { replay_state: 'duplicate_event', processing_result: 'duplicate_ignored' }, + ], + ); + assert.equal(await currentPlan(token), 'free'); +}); + +test('stale signed delivery and raw-body mutation fail before entitlement or ledger state changes', async () => { const { token, orgId } = await signupAndOrg(); const body = checkoutCompletedBody(orgId); const staleTimestamp = Math.floor(Date.now() / 1000) - 301; + const before = db.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_events').get().count; let response = await app.request('https://scopeweave.example/api/stripe/webhook', { method: 'POST', @@ -131,5 +166,6 @@ test('stale signed delivery and raw-body mutation fail before entitlement state }); assert.equal(response.status, 400); assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_events').get().count, before); assert.equal(await currentPlan(token), 'free'); }); From 48475f02ed1ef5303578d43d44453de2123743e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:30:26 +0900 Subject: [PATCH 05/25] feat(billing): expose verified Stripe payload hash evidence --- server/stripe_webhook.mjs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/server/stripe_webhook.mjs b/server/stripe_webhook.mjs index 1647fd67..ed28be76 100644 --- a/server/stripe_webhook.mjs +++ b/server/stripe_webhook.mjs @@ -1,4 +1,4 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; const STRIPE_WEBHOOK_MAX_BYTES = 256 * 1024; const STRIPE_SIGNATURE_HEADER_MAX_LENGTH = 4096; @@ -199,20 +199,22 @@ function parseVerifiedEvent(body) { * header is bounded, and the signed timestamp must be within five minutes of the * server clock. Multiple `v1` values are accepted for endpoint-secret rotation. * - * This function establishes transport authenticity only. It intentionally does - * not deduplicate event IDs, assume delivery ordering, or grant billing - * entitlements; those operations require durable provider-state reconciliation. + * This function establishes transport authenticity only. Callers that persist + * replay evidence can request the SHA-256 digest of those exact verified bytes; + * the raw body itself never needs to cross into durable storage. * * @param {Request} request Fetch-compatible request containing the raw webhook body * @param {object} options verifier configuration * @param {string} options.secret Stripe endpoint signing secret * @param {number} [options.nowSeconds] integer epoch seconds used for replay checks - * @returns {Promise>} verified bounded Stripe event object + * @param {boolean} [options.includeEvidence=false] return verified raw-byte digest with the parsed event + * @returns {Promise|{event: Record, payloadSha256: string}>} verified event, optionally with exact-byte digest evidence * @throws {StripeWebhookError} for unconfigured, oversized, malformed, or unauthenticated requests */ export async function verifyStripeWebhookRequest(request, { secret, nowSeconds = Math.floor(Date.now() / 1000), + includeEvidence = false, } = {}) { requireVerifierConfiguration(secret, nowSeconds); const body = await readBoundedRawBody(request); @@ -220,5 +222,10 @@ export async function verifyStripeWebhookRequest(request, { if (!signatureMatches(body, signatureHeader, secret, nowSeconds)) { throw webhookError('stripe_webhook_signature_invalid'); } - return parseVerifiedEvent(body); + const event = parseVerifiedEvent(body); + if (!includeEvidence) return event; + return { + event, + payloadSha256: createHash('sha256').update(body).digest('hex'), + }; } From 0382017ceec824afd0859953c19bb85e1e9cc1fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:30:56 +0900 Subject: [PATCH 06/25] feat(billing): bootstrap Stripe webhook event ledger --- server/db.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/server/db.mjs b/server/db.mjs index 7a27f461..c81016a9 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -8,6 +8,10 @@ import { createSqliteBillingCheckoutAttemptRepository, installBillingCheckoutAttemptSchema, } from './billing_checkout_attempt.mjs'; +import { + createSqliteStripeWebhookEventRepository, + installStripeWebhookEventSchema, +} from './stripe_webhook_event_ledger.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dbPath = process.env.SCOPEWEAVE_DB || join(__dirname, '..', 'data.db'); @@ -181,9 +185,11 @@ try { db.exec('ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAU try { db.exec('ALTER TABLE projects ADD COLUMN archived INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ } try { db.exec("ALTER TABLE projects ADD COLUMN methodology TEXT NOT NULL DEFAULT 'waterfall'"); } catch { /* already there */ } -// Billing attempt state is installed at bootstrap only, after referenced orgs exist. +// Billing state is installed at bootstrap only; request handlers never create schema. installBillingCheckoutAttemptSchema(db); export const billingCheckoutAttempts = createSqliteBillingCheckoutAttemptRepository(db); +installStripeWebhookEventSchema(db); +export const stripeWebhookEvents = createSqliteStripeWebhookEventRepository(db); // node:sqlite returns lastInsertRowid as number|bigint; normalize to Number. -export const rowid = (r) => Number(r.lastInsertRowid); \ No newline at end of file +export const rowid = (r) => Number(r.lastInsertRowid); From a9546c25debd01dcb870a37f636efd6d1654cfbb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:32:26 +0900 Subject: [PATCH 07/25] feat(billing): expose bootstrap-injected webhook recorder --- server/stripe_webhook_event_ledger.mjs | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/server/stripe_webhook_event_ledger.mjs b/server/stripe_webhook_event_ledger.mjs index d989afb0..f14254fb 100644 --- a/server/stripe_webhook_event_ledger.mjs +++ b/server/stripe_webhook_event_ledger.mjs @@ -1,6 +1,7 @@ const MAX_EVENT_FIELD_LENGTH = 255; const SHA256_PATTERN = /^[0-9a-f]{64}$/; const SAVEPOINT_NAME = 'billing_stripe_webhook_event_write'; +let configuredRecorder = null; /** Stable persistence-boundary error for verified Stripe webhook events. */ export class StripeWebhookLedgerError extends Error { @@ -90,6 +91,32 @@ function withSavepoint(database, operation) { } } +/** + * Install the process-local verified-event recorder during database bootstrap. + * + * Keeping recorder installation explicit avoids database side effects in pure + * signature-verification unit tests while ensuring the production app, which + * imports `db.mjs` before the webhook verifier, records every authenticated event. + * + * @param {(input: {event: Record, payloadSha256: string}) => unknown} recorder durable recorder function + * @returns {void} + */ +export function configureStripeWebhookEventRecorder(recorder) { + if (typeof recorder !== 'function') throw new TypeError('recorder must be a function'); + configuredRecorder = recorder; +} + +/** + * Record verified evidence when bootstrap has installed a durable recorder. + * Pure verifier-only consumers may intentionally run without one. + * + * @param {{event: Record, payloadSha256: string}} input verified event evidence + * @returns {unknown|null} recorder result, or null when no runtime recorder exists + */ +export function recordVerifiedStripeWebhookEvent(input) { + return configuredRecorder ? configuredRecorder(input) : null; +} + /** * Install normalized verified-event and delivery-evidence relations at bootstrap. * From a88680738a002e231ea56c935026e1a0a16235a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:32:59 +0900 Subject: [PATCH 08/25] feat(billing): bind webhook recorder at database bootstrap --- server/db.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/db.mjs b/server/db.mjs index c81016a9..7e90b51f 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -9,6 +9,7 @@ import { installBillingCheckoutAttemptSchema, } from './billing_checkout_attempt.mjs'; import { + configureStripeWebhookEventRecorder, createSqliteStripeWebhookEventRepository, installStripeWebhookEventSchema, } from './stripe_webhook_event_ledger.mjs'; @@ -190,6 +191,7 @@ installBillingCheckoutAttemptSchema(db); export const billingCheckoutAttempts = createSqliteBillingCheckoutAttemptRepository(db); installStripeWebhookEventSchema(db); export const stripeWebhookEvents = createSqliteStripeWebhookEventRepository(db); +configureStripeWebhookEventRecorder((evidence) => stripeWebhookEvents.recordVerifiedEvent(evidence)); // node:sqlite returns lastInsertRowid as number|bigint; normalize to Number. export const rowid = (r) => Number(r.lastInsertRowid); From 1fa143dbe4bd24c97405ef728a35f619549f2554 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:33:30 +0900 Subject: [PATCH 09/25] feat(billing): record authenticated Stripe event evidence --- server/stripe_webhook.mjs | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/server/stripe_webhook.mjs b/server/stripe_webhook.mjs index ed28be76..759f78bd 100644 --- a/server/stripe_webhook.mjs +++ b/server/stripe_webhook.mjs @@ -1,4 +1,8 @@ import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; +import { + StripeWebhookLedgerError, + recordVerifiedStripeWebhookEvent, +} from './stripe_webhook_event_ledger.mjs'; const STRIPE_WEBHOOK_MAX_BYTES = 256 * 1024; const STRIPE_SIGNATURE_HEADER_MAX_LENGTH = 4096; @@ -191,7 +195,7 @@ function parseVerifiedEvent(body) { } /** - * Verify and parse one Stripe webhook without mutating its signed request body. + * Verify, parse, and (when bootstrap configured it) durably record one Stripe webhook. * * Stripe signs `timestamp + "." + raw request body`; JSON parsing therefore * happens only after constant-time HMAC verification over the exact streamed @@ -199,9 +203,10 @@ function parseVerifiedEvent(body) { * header is bounded, and the signed timestamp must be within five minutes of the * server clock. Multiple `v1` values are accepted for endpoint-secret rotation. * - * This function establishes transport authenticity only. Callers that persist - * replay evidence can request the SHA-256 digest of those exact verified bytes; - * the raw body itself never needs to cross into durable storage. + * After authentication, a SHA-256 digest of the exact signed bytes is passed to + * the bootstrap-injected event recorder. No raw webhook payload is retained by + * that boundary. Pure verifier consumers without a configured recorder remain + * side-effect free. * * @param {Request} request Fetch-compatible request containing the raw webhook body * @param {object} options verifier configuration @@ -209,7 +214,7 @@ function parseVerifiedEvent(body) { * @param {number} [options.nowSeconds] integer epoch seconds used for replay checks * @param {boolean} [options.includeEvidence=false] return verified raw-byte digest with the parsed event * @returns {Promise|{event: Record, payloadSha256: string}>} verified event, optionally with exact-byte digest evidence - * @throws {StripeWebhookError} for unconfigured, oversized, malformed, or unauthenticated requests + * @throws {StripeWebhookError} for unconfigured, oversized, malformed, unauthenticated, conflicting, or unavailable persistence */ export async function verifyStripeWebhookRequest(request, { secret, @@ -223,9 +228,15 @@ export async function verifyStripeWebhookRequest(request, { throw webhookError('stripe_webhook_signature_invalid'); } const event = parseVerifiedEvent(body); + const payloadSha256 = createHash('sha256').update(body).digest('hex'); + try { + recordVerifiedStripeWebhookEvent({ event, payloadSha256 }); + } catch (error) { + if (error instanceof StripeWebhookLedgerError) { + throw webhookError(error.code, error.status); + } + throw webhookError('stripe_webhook_persistence_unavailable', 503); + } if (!includeEvidence) return event; - return { - event, - payloadSha256: createHash('sha256').update(body).digest('hex'), - }; + return { event, payloadSha256 }; } From 1f063a47b86253043e19b64c299bb662a347ab15 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:34:09 +0900 Subject: [PATCH 10/25] test(api): exercise concurrent Stripe replay recording --- tests/api/stripe-webhook.test.mjs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/api/stripe-webhook.test.mjs b/tests/api/stripe-webhook.test.mjs index 7e35efca..9ef7326f 100644 --- a/tests/api/stripe-webhook.test.mjs +++ b/tests/api/stripe-webhook.test.mjs @@ -102,7 +102,7 @@ test('verified webhook is durably recorded but does not grant entitlement before }); assert.equal(response.status, 200); - assert.deepEqual(await response.json(), { received: true, replayed: false }); + assert.deepEqual(await response.json(), { received: true }); assert.equal(response.headers.get('cache-control'), 'no-store'); assert.equal(db.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_events').get().count, 1); assert.deepEqual( @@ -116,18 +116,18 @@ test('verified webhook is durably recorded but does not grant entitlement before ); }); -test('exact duplicate verified event is acknowledged idempotently and retained as replay evidence', async () => { +test('concurrent duplicate verified events converge to one event and explicit replay evidence', async () => { const { token, orgId } = await signupAndOrg(); const body = checkoutCompletedBody(orgId); const headers = { ...jsonHeaders, 'stripe-signature': signatureHeader(body) }; - let response = await app.request('https://scopeweave.example/api/stripe/webhook', { method: 'POST', headers, body }); - assert.equal(response.status, 200); - assert.deepEqual(await response.json(), { received: true, replayed: false }); - - response = await app.request('https://scopeweave.example/api/stripe/webhook', { method: 'POST', headers, body }); - assert.equal(response.status, 200); - assert.deepEqual(await response.json(), { received: true, replayed: true }); + const [first, second] = await Promise.all([ + app.request('https://scopeweave.example/api/stripe/webhook', { method: 'POST', headers, body }), + app.request('https://scopeweave.example/api/stripe/webhook', { method: 'POST', headers, body }), + ]); + assert.deepEqual([first.status, second.status], [200, 200]); + assert.deepEqual(await first.json(), { received: true }); + assert.deepEqual(await second.json(), { received: true }); assert.equal(db.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_events WHERE event_id = ?').get(`evt_checkout_${orgId}`).count, 1); assert.deepEqual( db.prepare('SELECT replay_state, processing_result FROM billing_stripe_webhook_deliveries WHERE event_id = ? ORDER BY delivery_id').all(`evt_checkout_${orgId}`), From 73d30307c451f438acc6d30dd68d847fca4aa6c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:34:49 +0900 Subject: [PATCH 11/25] test(billing): cover verified webhook recorder integration --- ...ripe-webhook-recorder-integration.test.mjs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/unit/stripe-webhook-recorder-integration.test.mjs diff --git a/tests/unit/stripe-webhook-recorder-integration.test.mjs b/tests/unit/stripe-webhook-recorder-integration.test.mjs new file mode 100644 index 00000000..9f83692e --- /dev/null +++ b/tests/unit/stripe-webhook-recorder-integration.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { createHash, createHmac } from 'node:crypto'; +import { test } from 'node:test'; +import { + StripeWebhookLedgerError, + configureStripeWebhookEventRecorder, +} from '../../server/stripe_webhook_event_ledger.mjs'; +import { + StripeWebhookError, + verifyStripeWebhookRequest, +} from '../../server/stripe_webhook.mjs'; + +const SECRET = 'whsec_scopeweave_recorder_unit'; +const NOW_SECONDS = 1_787_000_100; + +function body(id = 'evt_recorder_1') { + return JSON.stringify({ + id, + object: 'event', + api_version: '2025-02-24.acacia', + created: 1_787_000_000, + type: 'customer.subscription.updated', + request: { id: 'req_recorder_1', idempotency_key: null }, + data: { object: { id: 'sub_recorder_1', object: 'subscription' } }, + }); +} + +function requestFor(rawBody) { + const signature = createHmac('sha256', SECRET) + .update(String(NOW_SECONDS)) + .update('.') + .update(rawBody) + .digest('hex'); + return new Request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'stripe-signature': `t=${NOW_SECONDS},v1=${signature}`, + }, + body: rawBody, + }); +} + +test('pure verifier exposes exact-byte hash evidence without requiring runtime persistence', async () => { + const rawBody = body(); + const verified = await verifyStripeWebhookRequest(requestFor(rawBody), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + includeEvidence: true, + }); + assert.equal(verified.event.id, 'evt_recorder_1'); + assert.equal(verified.payloadSha256, createHash('sha256').update(rawBody).digest('hex')); +}); + +test('runtime recorder configuration validates and receives verified exact-byte evidence', async () => { + assert.throws(() => configureStripeWebhookEventRecorder(null), TypeError); + let captured; + configureStripeWebhookEventRecorder((evidence) => { captured = evidence; }); + + const rawBody = body('evt_recorder_2'); + const event = await verifyStripeWebhookRequest(requestFor(rawBody), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }); + assert.equal(event.id, 'evt_recorder_2'); + assert.equal(captured.event.id, 'evt_recorder_2'); + assert.equal(captured.payloadSha256, createHash('sha256').update(rawBody).digest('hex')); +}); + +test('known ledger failures keep stable sanitized status while unknown persistence failures become unavailable', async () => { + configureStripeWebhookEventRecorder(() => { + throw new StripeWebhookLedgerError('stripe_webhook_event_conflict', 409); + }); + await assert.rejects( + verifyStripeWebhookRequest(requestFor(body('evt_conflict')), { secret: SECRET, nowSeconds: NOW_SECONDS }), + (error) => error instanceof StripeWebhookError && error.code === 'stripe_webhook_event_conflict' && error.status === 409, + ); + + configureStripeWebhookEventRecorder(() => { throw new Error('database path intentionally hidden'); }); + await assert.rejects( + verifyStripeWebhookRequest(requestFor(body('evt_unavailable')), { secret: SECRET, nowSeconds: NOW_SECONDS }), + (error) => error instanceof StripeWebhookError && error.code === 'stripe_webhook_persistence_unavailable' && error.status === 503, + ); +}); From 743c7c35c286dbbb5e68a5e7357d6bccd2936b92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:44:38 +0900 Subject: [PATCH 12/25] test(billing): normalize sqlite rows for hosted assertions --- tests/unit/stripe-webhook-event-ledger.test.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/stripe-webhook-event-ledger.test.mjs b/tests/unit/stripe-webhook-event-ledger.test.mjs index 4664f349..7f129a31 100644 --- a/tests/unit/stripe-webhook-event-ledger.test.mjs +++ b/tests/unit/stripe-webhook-event-ledger.test.mjs @@ -54,7 +54,7 @@ test('first verified event stores bounded immutable metadata and one non-replay assert.equal(result.replayed, false); assert.equal(result.eventId, 'evt_scopeweave_1'); - assert.deepEqual(database.prepare('SELECT * FROM billing_stripe_webhook_events').get(), { + assert.deepEqual({ ...database.prepare('SELECT * FROM billing_stripe_webhook_events').get() }, { event_id: 'evt_scopeweave_1', provider_created_at_sec: 1_787_000_000, event_type: 'customer.subscription.updated', @@ -65,7 +65,7 @@ test('first verified event stores bounded immutable metadata and one non-replay payload_sha256: HASH_A, first_received_at_ms: 1_787_000_100_000, }); - assert.deepEqual(database.prepare('SELECT event_id, replay_state, processing_result FROM billing_stripe_webhook_deliveries').get(), { + assert.deepEqual({ ...database.prepare('SELECT event_id, replay_state, processing_result FROM billing_stripe_webhook_deliveries').get() }, { event_id: 'evt_scopeweave_1', replay_state: 'first_delivery', processing_result: 'received', }); }); @@ -78,7 +78,8 @@ test('exact duplicate event IDs are idempotent and recorded as replay evidence', assert.equal(replay.replayed, true); assert.equal(database.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_events').get().count, 1); assert.deepEqual( - database.prepare('SELECT replay_state, processing_result FROM billing_stripe_webhook_deliveries ORDER BY delivery_id').all(), + database.prepare('SELECT replay_state, processing_result FROM billing_stripe_webhook_deliveries ORDER BY delivery_id').all() + .map((row) => ({ ...row })), [ { replay_state: 'first_delivery', processing_result: 'received' }, { replay_state: 'duplicate_event', processing_result: 'duplicate_ignored' }, From 86e334fa386adbeff6fa96214ec5f72414ee4861 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:48:34 +0900 Subject: [PATCH 13/25] test(billing): normalize sqlite rows in webhook API assertions --- tests/api/stripe-webhook.test.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/api/stripe-webhook.test.mjs b/tests/api/stripe-webhook.test.mjs index 9ef7326f..8ca7de6e 100644 --- a/tests/api/stripe-webhook.test.mjs +++ b/tests/api/stripe-webhook.test.mjs @@ -106,7 +106,7 @@ test('verified webhook is durably recorded but does not grant entitlement before assert.equal(response.headers.get('cache-control'), 'no-store'); assert.equal(db.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_events').get().count, 1); assert.deepEqual( - db.prepare('SELECT replay_state, processing_result FROM billing_stripe_webhook_deliveries ORDER BY delivery_id DESC LIMIT 1').get(), + { ...db.prepare('SELECT replay_state, processing_result FROM billing_stripe_webhook_deliveries ORDER BY delivery_id DESC LIMIT 1').get() }, { replay_state: 'first_delivery', processing_result: 'received' }, ); assert.equal( @@ -130,7 +130,8 @@ test('concurrent duplicate verified events converge to one event and explicit re assert.deepEqual(await second.json(), { received: true }); assert.equal(db.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_events WHERE event_id = ?').get(`evt_checkout_${orgId}`).count, 1); assert.deepEqual( - db.prepare('SELECT replay_state, processing_result FROM billing_stripe_webhook_deliveries WHERE event_id = ? ORDER BY delivery_id').all(`evt_checkout_${orgId}`), + db.prepare('SELECT replay_state, processing_result FROM billing_stripe_webhook_deliveries WHERE event_id = ? ORDER BY delivery_id').all(`evt_checkout_${orgId}`) + .map((row) => ({ ...row })), [ { replay_state: 'first_delivery', processing_result: 'received' }, { replay_state: 'duplicate_event', processing_result: 'duplicate_ignored' }, From e1a403cb4c1c3bc45902db09aca4349f07734e6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:16:43 +0900 Subject: [PATCH 14/25] test(billing): reject malformed webhook request envelopes --- package.json | 4 ++-- tests/unit/coverage-script-contract.test.mjs | 20 +++++++++++++++++++ .../unit/stripe-webhook-event-ledger.test.mjs | 5 ++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 5827eb28..8e1e0550 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/stripe-webhook.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/stripe_webhook.mjs --include=server/stripe_webhook_event_ledger.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 6e615e5e..8f8fc7a3 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -44,6 +44,11 @@ assert.match( /--include=server\/stripe_webhook\.mjs/, 'the Stripe webhook trust boundary is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/stripe_webhook_event_ledger\.mjs/, + 'the verified Stripe webhook event ledger is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, @@ -69,6 +74,21 @@ assert.match( /tests\/unit\/stripe-webhook-boundary\.test\.mjs/, 'the Stripe webhook trust regression executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/stripe-webhook-event-ledger\.test\.mjs/, + 'the durable Stripe webhook event-ledger regression executes under c8', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/stripe-webhook-recorder-integration\.test\.mjs/, + 'the verified-event recorder integration regression executes under c8', +); +assert.match( + scripts['test:unit'], + /tests\/unit\/stripe-webhook-recorder-integration\.test\.mjs/, + 'normal unit CI executes the verified-event recorder integration regression', +); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/, diff --git a/tests/unit/stripe-webhook-event-ledger.test.mjs b/tests/unit/stripe-webhook-event-ledger.test.mjs index 7f129a31..807bee27 100644 --- a/tests/unit/stripe-webhook-event-ledger.test.mjs +++ b/tests/unit/stripe-webhook-event-ledger.test.mjs @@ -98,7 +98,7 @@ test('same event ID with a different verified payload hash fails closed without assert.equal(database.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_deliveries').get().count, 1); }); -test('malformed provider ordering and object identity metadata fail before persistence', () => { +test('malformed provider ordering, object identity, and request envelopes fail before persistence', () => { const { database, repository } = setup(); const invalid = [ event({ created: -1 }), @@ -106,6 +106,9 @@ test('malformed provider ordering and object identity metadata fail before persi event({ data: {} }), event({ data: { object: { id: '', object: 'subscription' } } }), event({ request: { id: 'x'.repeat(256) } }), + event({ request: 'req_not_an_object' }), + event({ request: [] }), + event({ request: {} }), ]; for (const candidate of invalid) { assert.throws( From 67dd5e70ec6bb273e4d9ff1967a09be0f305cb08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:20:24 +0900 Subject: [PATCH 15/25] fix(billing): validate Stripe request metadata envelope --- server/stripe_webhook_event_ledger.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/stripe_webhook_event_ledger.mjs b/server/stripe_webhook_event_ledger.mjs index f14254fb..723297f1 100644 --- a/server/stripe_webhook_event_ledger.mjs +++ b/server/stripe_webhook_event_ledger.mjs @@ -55,6 +55,14 @@ function normalizedPayloadHash(value) { return value.toLowerCase(); } +function normalizedRequestId(request) { + if (request == null) return null; + if (typeof request !== 'object' || Array.isArray(request)) { + throw ledgerError('stripe_webhook_event_invalid'); + } + return requiredString(request.id); +} + function normalizedEvent(event) { if (!event || typeof event !== 'object' || Array.isArray(event)) { throw ledgerError('stripe_webhook_event_invalid'); @@ -63,7 +71,7 @@ function normalizedEvent(event) { if (!providerObject || typeof providerObject !== 'object' || Array.isArray(providerObject)) { throw ledgerError('stripe_webhook_event_invalid'); } - const requestId = event.request == null ? null : nullableString(event.request?.id); + const requestId = normalizedRequestId(event.request); return { eventId: requiredString(event.id), providerCreatedAtSec: safeCreated(event.created), From 7e0b2388d5b3b16dcb54f574390c4530478269ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:24:04 +0900 Subject: [PATCH 16/25] docs(billing): trace verified webhook event ledger --- docs/doctoring/stripe-webhook-event-ledger.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/doctoring/stripe-webhook-event-ledger.md diff --git a/docs/doctoring/stripe-webhook-event-ledger.md b/docs/doctoring/stripe-webhook-event-ledger.md new file mode 100644 index 00000000..7f8559c7 --- /dev/null +++ b/docs/doctoring/stripe-webhook-event-ledger.md @@ -0,0 +1,114 @@ +# Verified Stripe webhook event ledger + +## Status and authority + +**Status: active stacked PR evidence, not protected-`develop` shipped truth.** + +This record belongs to PR #521 and is stacked on the raw-body signature boundary in PR #520. The ledger is deliberately downstream of signature verification: it records evidence only after the exact request bytes have passed the Stripe signature/timestamp boundary. It is not an entitlement authority, does not infer subscription state, and does not make out-of-order webhook delivery safe by itself. + +Issue #488 remains open for the larger monotonic subscription lifecycle, authoritative provider-state reconciliation, normalized customer/subscription/payment/entitlement state, migration/recovery, retention, and release acceptance. + +## Buyer and control objective + +A commercial billing system needs durable evidence that answers four different questions without retaining a signed request body indefinitely: + +1. Which verified Stripe event identity was first accepted? +2. Which immutable provider/object metadata arrived with that verified event? +3. Did the same event ID arrive again, and was the signed body byte-for-byte equivalent? +4. Can a replay or persistence conflict be distinguished from an entitlement transition? + +The ledger addresses only that evidence boundary. Stripe documents that webhook endpoints can receive duplicate events and recommends logging processed event IDs; Stripe also warns that event delivery order is not guaranteed. The ScopeWeave implementation therefore treats duplicate receipt as an auditable delivery fact while keeping downstream reconciliation as a separate authority. + +## Normalized storage model + +`installStripeWebhookEventSchema(database)` creates two relations during database bootstrap, never during an individual webhook request: + +### `billing_stripe_webhook_events` + +One immutable fact row per verified Stripe `event_id`: + +- `event_id` — bounded provider event identity and primary key; +- `provider_created_at_sec` — Stripe event creation time as a non-negative safe integer; +- `event_type` — bounded Stripe event type; +- `object_id` and `object_type` — bounded identity/type of `data.object`; +- `api_version` — optional bounded provider API-version evidence; +- `request_id` — optional bounded Stripe request identity, accepted only from a valid non-array request envelope; +- `payload_sha256` — SHA-256 of the exact signed request bytes; +- `first_received_at_ms` — trusted local receipt time. + +### `billing_stripe_webhook_deliveries` + +One row per accepted delivery attempt, referencing the immutable event fact: + +- `delivery_id` — local surrogate identity; +- `event_id` — foreign key to `billing_stripe_webhook_events`; +- `received_at_ms` — trusted local receipt time; +- `replay_state` — `first_delivery` or `duplicate_event`; +- `processing_result` — `received` or `duplicate_ignored`. + +Indexes use descriptive multiword snake_case names. The event fact and delivery history are separated so repeated deliveries do not denormalize provider metadata. The signed raw JSON body is not retained by this ledger. + +## Replay and conflict semantics + +`recordVerifiedEvent({ event, payloadSha256 })` normalizes and bounds the provider evidence before opening its write savepoint. + +- A new event ID inserts one immutable event fact and one `first_delivery` / `received` delivery row. +- An existing event ID with the same exact-byte SHA-256 leaves the immutable event fact unchanged and appends a `duplicate_event` / `duplicate_ignored` delivery row. +- An existing event ID with a different exact-byte SHA-256 fails closed with stable `stripe_webhook_event_conflict` / HTTP 409 and records no false replay evidence. +- Malformed event ordering, object identity, API/request metadata, payload hashes, or trusted-clock values fail before persistence with stable sanitized errors. + +The savepoint encloses the event/delivery mutation together. On failure, `ROLLBACK TO` restores the state at the savepoint before it is released. This composes with an outer SQLite transaction rather than pretending that `RELEASE SAVEPOINT` alone has durably committed to storage. + +## Runtime integration boundary + +Database bootstrap creates the repository and installs `recordVerifiedEvent` through `configureStripeWebhookEventRecorder(...)`. The webhook verifier exposes the SHA-256 derived from the exact bytes it authenticated and calls the configured recorder only after verification succeeds. + +Verifier-only consumers may intentionally have no runtime recorder; this keeps pure signature tests and reusable verification code free from hidden database creation. The production application imports database bootstrap before serving the webhook route, so the runtime path has a recorder installed. + +Known `StripeWebhookLedgerError` values preserve stable sanitized status/code semantics. Unexpected persistence failures collapse to the existing unavailable boundary rather than leaking SQLite/provider details. + +## Security and privacy boundary + +The ledger stores bounded identifiers, timestamps, type/version metadata, and an exact-byte digest. It intentionally does **not** store: + +- the signed raw webhook body; +- Stripe API keys or webhook secrets; +- application session tokens; +- entitlement decisions derived from the event; +- arbitrary provider response/error text. + +A SHA-256 digest is evidence of byte identity, not a confidentiality mechanism or a substitute for signature verification. Retention/export policy for these billing evidence rows remains explicit #488 follow-up work; this active PR does not claim SOC 2, CSAP, or any other certification. + +## TDD and current verification evidence + +The event-ledger implementation was followed by a focused review of its malformed-input and coverage boundary. Regression commit `e1a403cb4c1c3bc45902db09aca4349f07734e6d` added invalid non-null Stripe `request` envelopes and made the recorder-integration tests part of normal and c8 execution. The then-current hosted Server Tests run observed the intended failure before the production repair. + +Commit `67dd5e70ec6bb273e4d9ff1967a09be0f305cb08` added the narrow `normalizedRequestId(...)` production check. The subsequent repository-native unit/API, browser, dependency, and OSV jobs completed successfully, including the new malformed-envelope and recorder-integration regressions. + +Those Server Tests results are **not merge-grade exact-head evidence** under the repository's current execution contract: the job log shows `actions/checkout` fetched PR #521's synthetic merge ref and executed commit `250b24a31e5be818dfe63d036a12611f0f3723ba`, not contributor head `67dd5e70ec6bb273e4d9ff1967a09be0f305cb08`. The repository workflow checkout integrity gap must be repaired and current-head evidence re-established before any protected integration. Green synthetic-merge evidence is preserved here only as causal test information, never promoted to the exact-head merge gate. + +## Acceptance trace + +Executable contracts include: + +- `tests/unit/stripe-webhook-event-ledger.test.mjs` — schema shape, raw-body non-retention, first delivery, exact replay, conflicting-byte rejection, and malformed provider metadata; +- `tests/unit/stripe-webhook-recorder-integration.test.mjs` — verifier-only behavior, runtime recorder installation, exact-byte evidence forwarding, and sanitized persistence failures; +- `tests/api/stripe-webhook.test.mjs` — real Hono/SQLite route behavior, signed durable receipt without entitlement mutation, concurrent duplicate convergence, and signature/body-mutation rejection; +- `tests/unit/coverage-script-contract.test.mjs` — locks the production ledger and both focused suites into the canonical c8 producer; +- `package.json` — executes the focused suites in normal unit and owned-production coverage paths. + +After every head movement, predecessor runs and reviews are historical. The PR stays Draft until the unchanged exact head has applicable deterministic CI/security/dependency/coverage evidence and the live review/ruleset requirements can be satisfied. + +## Rollback and recovery + +Before protected integration, rollback is source-only: remove the ledger module, bootstrap recorder wiring, verifier recorder integration, tests/coverage registrations, this record, and the corresponding Unreleased changelog entry together. + +After a future shipped migration creates durable ledger rows, rollback must be a reviewed database migration/recovery operation. Do not drop evidence tables merely to revert application code, and do not restore direct entitlement mutation from webhook payloads as a fallback. A database restore must preserve schema and billing evidence from one verified recovery point. + +## References + +SQLite Consortium. (n.d.). *Savepoints*. SQLite. https://www.sqlite.org/lang_savepoint.html + +SQLite Consortium. (n.d.). *SQLite foreign key support*. SQLite. https://www.sqlite.org/foreignkeys.html + +Stripe. (n.d.). *Receive Stripe events in your webhook endpoint*. Stripe Documentation. https://docs.stripe.com/webhooks From 98b11f5ceed231b5d36c2b1464167c79723793ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:24:47 +0900 Subject: [PATCH 17/25] docs(changelog): record verified webhook event evidence --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 527265d5..4054dd2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Persist verified Stripe webhook event metadata and per-delivery replay evidence + after raw-body signature verification without retaining the signed raw body; + exact event-ID/hash duplicates are idempotent, conflicting bytes and malformed + request envelopes fail closed, and no webhook event directly grants entitlement + before authoritative lifecycle reconciliation. - Persisted a tenant/price-scoped Stripe Checkout attempt identity and opaque idempotency key before live Session creation, reusing unresolved identity only inside a 23-hour safety window; network/abort and Stripe 5xx outcomes remain From e0b01deac9d6c553791ef5498fffa6f16c9b12ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:00:30 +0900 Subject: [PATCH 18/25] test(billing): require 3NF webhook delivery evidence --- tests/unit/stripe-webhook-event-ledger.test.mjs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/unit/stripe-webhook-event-ledger.test.mjs b/tests/unit/stripe-webhook-event-ledger.test.mjs index 807bee27..293f41d9 100644 --- a/tests/unit/stripe-webhook-event-ledger.test.mjs +++ b/tests/unit/stripe-webhook-event-ledger.test.mjs @@ -31,7 +31,7 @@ function setup(now = () => 1_787_000_100_000) { return { database, repository }; } -test('schema is bootstrap-installed, normalized, and does not retain raw payloads', () => { +test('schema is bootstrap-installed, 3NF-normalized, and does not retain raw payloads', () => { const { database } = setup(); installStripeWebhookEventSchema(database); @@ -43,7 +43,7 @@ test('schema is bootstrap-installed, normalized, and does not retain raw payload 'api_version', 'request_id', 'payload_sha256', 'first_received_at_ms', ]); assert.deepEqual(deliveryColumns, [ - 'delivery_id', 'event_id', 'received_at_ms', 'replay_state', 'processing_result', + 'delivery_id', 'event_id', 'received_at_ms', 'replay_state', ]); assert.equal(eventColumns.some((name) => /raw|payload_json|body/i.test(name)), false); }); @@ -65,8 +65,8 @@ test('first verified event stores bounded immutable metadata and one non-replay payload_sha256: HASH_A, first_received_at_ms: 1_787_000_100_000, }); - assert.deepEqual({ ...database.prepare('SELECT event_id, replay_state, processing_result FROM billing_stripe_webhook_deliveries').get() }, { - event_id: 'evt_scopeweave_1', replay_state: 'first_delivery', processing_result: 'received', + assert.deepEqual({ ...database.prepare('SELECT event_id, replay_state FROM billing_stripe_webhook_deliveries').get() }, { + event_id: 'evt_scopeweave_1', replay_state: 'first_delivery', }); }); @@ -78,11 +78,11 @@ test('exact duplicate event IDs are idempotent and recorded as replay evidence', assert.equal(replay.replayed, true); assert.equal(database.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_events').get().count, 1); assert.deepEqual( - database.prepare('SELECT replay_state, processing_result FROM billing_stripe_webhook_deliveries ORDER BY delivery_id').all() + database.prepare('SELECT replay_state FROM billing_stripe_webhook_deliveries ORDER BY delivery_id').all() .map((row) => ({ ...row })), [ - { replay_state: 'first_delivery', processing_result: 'received' }, - { replay_state: 'duplicate_event', processing_result: 'duplicate_ignored' }, + { replay_state: 'first_delivery' }, + { replay_state: 'duplicate_event' }, ], ); }); From 8e923e6098ab55d77ded088340ca741ed2dd1835 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:01:38 +0900 Subject: [PATCH 19/25] fix(billing): normalize webhook delivery evidence to 3NF --- server/stripe_webhook_event_ledger.mjs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/server/stripe_webhook_event_ledger.mjs b/server/stripe_webhook_event_ledger.mjs index 723297f1..7f1b82c7 100644 --- a/server/stripe_webhook_event_ledger.mjs +++ b/server/stripe_webhook_event_ledger.mjs @@ -130,7 +130,8 @@ export function recordVerifiedStripeWebhookEvent(input) { * * Event facts are stored once by immutable Stripe event ID. Delivery attempts are * a separate one-to-many relation so retries remain auditable without duplicating - * event metadata or retaining the signed raw JSON body. + * event metadata or retaining the signed raw JSON body. `replay_state` is the + * delivery outcome; no second result column repeats that same functional fact. * * @param {import('node:sqlite').DatabaseSync} database open SQLite database * @returns {void} @@ -155,8 +156,7 @@ export function installStripeWebhookEventSchema(database) { delivery_id INTEGER PRIMARY KEY, event_id TEXT NOT NULL REFERENCES billing_stripe_webhook_events(event_id) ON DELETE CASCADE, received_at_ms INTEGER NOT NULL CHECK(received_at_ms >= 0), - replay_state TEXT NOT NULL CHECK(replay_state IN ('first_delivery','duplicate_event')), - processing_result TEXT NOT NULL CHECK(processing_result IN ('received','duplicate_ignored')) + replay_state TEXT NOT NULL CHECK(replay_state IN ('first_delivery','duplicate_event')) ); CREATE INDEX IF NOT EXISTS billing_stripe_webhook_event_deliveries ON billing_stripe_webhook_deliveries(event_id, delivery_id); @@ -193,8 +193,8 @@ export function createSqliteStripeWebhookEventRepository(database, { now = Date. `); const insertDelivery = database.prepare(` INSERT INTO billing_stripe_webhook_deliveries( - event_id, received_at_ms, replay_state, processing_result - ) VALUES(?,?,?,?) + event_id, received_at_ms, replay_state + ) VALUES(?,?,?) `); return { @@ -214,7 +214,6 @@ export function createSqliteStripeWebhookEventRepository(database, { now = Date. normalized.eventId, receivedAtMs, 'duplicate_event', - 'duplicate_ignored', ); return { eventId: normalized.eventId, @@ -238,7 +237,6 @@ export function createSqliteStripeWebhookEventRepository(database, { now = Date. normalized.eventId, receivedAtMs, 'first_delivery', - 'received', ); return { eventId: normalized.eventId, From 8256af240af6b77001e9d25d76861ad3d3abeae6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:03:23 +0900 Subject: [PATCH 20/25] test(billing): align webhook API evidence with 3NF ledger --- tests/api/stripe-webhook.test.mjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/api/stripe-webhook.test.mjs b/tests/api/stripe-webhook.test.mjs index 8ca7de6e..5f4e9721 100644 --- a/tests/api/stripe-webhook.test.mjs +++ b/tests/api/stripe-webhook.test.mjs @@ -106,8 +106,8 @@ test('verified webhook is durably recorded but does not grant entitlement before assert.equal(response.headers.get('cache-control'), 'no-store'); assert.equal(db.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_events').get().count, 1); assert.deepEqual( - { ...db.prepare('SELECT replay_state, processing_result FROM billing_stripe_webhook_deliveries ORDER BY delivery_id DESC LIMIT 1').get() }, - { replay_state: 'first_delivery', processing_result: 'received' }, + { ...db.prepare('SELECT replay_state FROM billing_stripe_webhook_deliveries ORDER BY delivery_id DESC LIMIT 1').get() }, + { replay_state: 'first_delivery' }, ); assert.equal( await currentPlan(token), @@ -130,11 +130,11 @@ test('concurrent duplicate verified events converge to one event and explicit re assert.deepEqual(await second.json(), { received: true }); assert.equal(db.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_events WHERE event_id = ?').get(`evt_checkout_${orgId}`).count, 1); assert.deepEqual( - db.prepare('SELECT replay_state, processing_result FROM billing_stripe_webhook_deliveries WHERE event_id = ? ORDER BY delivery_id').all(`evt_checkout_${orgId}`) + db.prepare('SELECT replay_state FROM billing_stripe_webhook_deliveries WHERE event_id = ? ORDER BY delivery_id').all(`evt_checkout_${orgId}`) .map((row) => ({ ...row })), [ - { replay_state: 'first_delivery', processing_result: 'received' }, - { replay_state: 'duplicate_event', processing_result: 'duplicate_ignored' }, + { replay_state: 'first_delivery' }, + { replay_state: 'duplicate_event' }, ], ); assert.equal(await currentPlan(token), 'free'); From 86aba59abab6b90ea6ff4f118e771ed919c93034 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:04:14 +0900 Subject: [PATCH 21/25] docs(billing): trace 3NF webhook ledger repair --- docs/doctoring/stripe-webhook-event-ledger.md | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/docs/doctoring/stripe-webhook-event-ledger.md b/docs/doctoring/stripe-webhook-event-ledger.md index 7f8559c7..ac686373 100644 --- a/docs/doctoring/stripe-webhook-event-ledger.md +++ b/docs/doctoring/stripe-webhook-event-ledger.md @@ -43,17 +43,16 @@ One row per accepted delivery attempt, referencing the immutable event fact: - `delivery_id` — local surrogate identity; - `event_id` — foreign key to `billing_stripe_webhook_events`; - `received_at_ms` — trusted local receipt time; -- `replay_state` — `first_delivery` or `duplicate_event`; -- `processing_result` — `received` or `duplicate_ignored`. +- `replay_state` — `first_delivery` or `duplicate_event`. -Indexes use descriptive multiword snake_case names. The event fact and delivery history are separated so repeated deliveries do not denormalize provider metadata. The signed raw JSON body is not retained by this ledger. +Indexes use descriptive multiword snake_case names. The event fact and delivery history are separated so repeated deliveries do not denormalize provider metadata. `replay_state` is the sole persisted delivery classification: the earlier draft also stored a `processing_result` whose value was completely determined by `replay_state`, so that redundant dependent column was removed to preserve third normal form. The signed raw JSON body is not retained by this ledger. ## Replay and conflict semantics `recordVerifiedEvent({ event, payloadSha256 })` normalizes and bounds the provider evidence before opening its write savepoint. -- A new event ID inserts one immutable event fact and one `first_delivery` / `received` delivery row. -- An existing event ID with the same exact-byte SHA-256 leaves the immutable event fact unchanged and appends a `duplicate_event` / `duplicate_ignored` delivery row. +- A new event ID inserts one immutable event fact and one `first_delivery` row. +- An existing event ID with the same exact-byte SHA-256 leaves the immutable event fact unchanged and appends a `duplicate_event` row. - An existing event ID with a different exact-byte SHA-256 fails closed with stable `stripe_webhook_event_conflict` / HTTP 409 and records no false replay evidence. - Malformed event ordering, object identity, API/request metadata, payload hashes, or trusted-clock values fail before persistence with stable sanitized errors. @@ -81,19 +80,19 @@ A SHA-256 digest is evidence of byte identity, not a confidentiality mechanism o ## TDD and current verification evidence -The event-ledger implementation was followed by a focused review of its malformed-input and coverage boundary. Regression commit `e1a403cb4c1c3bc45902db09aca4349f07734e6d` added invalid non-null Stripe `request` envelopes and made the recorder-integration tests part of normal and c8 execution. The then-current hosted Server Tests run observed the intended failure before the production repair. +The event-ledger implementation was first hardened for malformed provider metadata. Regression commit `e1a403cb4c1c3bc45902db09aca4349f07734e6d` added invalid non-null Stripe `request` envelopes and made the recorder-integration tests part of normal and c8 execution. The then-current hosted Server Tests run observed the intended failure. Commit `67dd5e70ec6bb273e4d9ff1967a09be0f305cb08` added the narrow `normalizedRequestId(...)` production check, after which the corresponding repository-native workloads completed successfully. -Commit `67dd5e70ec6bb273e4d9ff1967a09be0f305cb08` added the narrow `normalizedRequestId(...)` production check. The subsequent repository-native unit/API, browser, dependency, and OSV jobs completed successfully, including the new malformed-envelope and recorder-integration regressions. +A second review found a data-normalization defect in the delivery relation: `processing_result` was a deterministic restatement of `replay_state`. Regression commit `e0b01deac9d6c553791ef5498fffa6f16c9b12ea` changed the executable schema contract to require only one delivery classification. Hosted Server Tests run `31925596498`, `unit-and-api` job `95112552686`, then failed in the unit suite as expected. Commit `8e923e6098ab55d77ded088340ca741ed2dd1835` removed the redundant production column and insert value. The next hosted run proved the unit suite green but exposed a stale API assertion that still queried the removed column; `8256af240af6b77001e9d25d76861ad3d3abeae6` aligned that real route regression with the normalized schema. All post-push evidence remains head-specific and must be re-established after this documentation commit. -Those Server Tests results are **not merge-grade exact-head evidence** under the repository's current execution contract: the job log shows `actions/checkout` fetched PR #521's synthetic merge ref and executed commit `250b24a31e5be818dfe63d036a12611f0f3723ba`, not contributor head `67dd5e70ec6bb273e4d9ff1967a09be0f305cb08`. The repository workflow checkout integrity gap must be repaired and current-head evidence re-established before any protected integration. Green synthetic-merge evidence is preserved here only as causal test information, never promoted to the exact-head merge gate. +Those Server Tests observations are **causal test evidence, not merge-grade exact-head evidence** under the repository's current protected-shipped workflow. PR #521 is based on a branch that still contains the older default pull-request checkout behavior, so its Server Tests may execute GitHub's synthetic merge ref. PR #523 separately repairs that repository-owned evidence-integrity gap. Until that fix is protected-shipped and this stack is revalidated against the live base, no synthetic-merge success is promoted to exact-current-head merge evidence. ## Acceptance trace Executable contracts include: -- `tests/unit/stripe-webhook-event-ledger.test.mjs` — schema shape, raw-body non-retention, first delivery, exact replay, conflicting-byte rejection, and malformed provider metadata; +- `tests/unit/stripe-webhook-event-ledger.test.mjs` — 3NF schema shape, raw-body non-retention, first delivery, exact replay, conflicting-byte rejection, and malformed provider metadata; - `tests/unit/stripe-webhook-recorder-integration.test.mjs` — verifier-only behavior, runtime recorder installation, exact-byte evidence forwarding, and sanitized persistence failures; -- `tests/api/stripe-webhook.test.mjs` — real Hono/SQLite route behavior, signed durable receipt without entitlement mutation, concurrent duplicate convergence, and signature/body-mutation rejection; +- `tests/api/stripe-webhook.test.mjs` — real Hono/SQLite route behavior, signed durable receipt without entitlement mutation, concurrent duplicate convergence, and signature/body-mutation rejection using the normalized delivery evidence model; - `tests/unit/coverage-script-contract.test.mjs` — locks the production ledger and both focused suites into the canonical c8 producer; - `package.json` — executes the focused suites in normal unit and owned-production coverage paths. From b3cd0662db64f89c793336e1f306042935c678de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 17:53:02 +0900 Subject: [PATCH 22/25] test(billing): preserve webhook ledger causal failure on rollback cleanup --- .../unit/stripe-webhook-event-ledger.test.mjs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/unit/stripe-webhook-event-ledger.test.mjs b/tests/unit/stripe-webhook-event-ledger.test.mjs index 293f41d9..421190ff 100644 --- a/tests/unit/stripe-webhook-event-ledger.test.mjs +++ b/tests/unit/stripe-webhook-event-ledger.test.mjs @@ -118,3 +118,39 @@ test('malformed provider ordering, object identity, and request envelopes fail b } assert.equal(database.prepare('SELECT COUNT(*) AS count FROM billing_stripe_webhook_events').get().count, 0); }); + +test('savepoint rollback cleanup preserves the causal delivery failure and never releases unconfirmed state', () => { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON'); + installStripeWebhookEventSchema(database); + database.exec(` + CREATE TRIGGER billing_stripe_test_delivery_failure + BEFORE INSERT ON billing_stripe_webhook_deliveries + BEGIN + SELECT RAISE(ABORT, 'causal webhook delivery write failure'); + END; + `); + + const executed = []; + const guardedDatabase = { + prepare: database.prepare.bind(database), + exec(sql) { + executed.push(sql); + if (sql === 'ROLLBACK TO SAVEPOINT billing_stripe_webhook_event_write') { + throw new Error('simulated rollback cleanup failure'); + } + return database.exec(sql); + }, + }; + const repository = createSqliteStripeWebhookEventRepository(guardedDatabase); + + assert.throws( + () => repository.recordVerifiedEvent({ event: event(), payloadSha256: HASH_A }), + /causal webhook delivery write failure/, + ); + assert.equal( + executed.filter((sql) => sql === 'RELEASE SAVEPOINT billing_stripe_webhook_event_write').length, + 0, + 'failed rollback must not release an unconfirmed savepoint and accidentally commit partial event state', + ); +}); From 4f23e0bfae06b561f18a1394b807c885bc7dee62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 17:54:09 +0900 Subject: [PATCH 23/25] fix(billing): fail closed on webhook ledger savepoint cleanup --- server/stripe_webhook_event_ledger.mjs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/server/stripe_webhook_event_ledger.mjs b/server/stripe_webhook_event_ledger.mjs index 7f1b82c7..4de8ef3d 100644 --- a/server/stripe_webhook_event_ledger.mjs +++ b/server/stripe_webhook_event_ledger.mjs @@ -90,10 +90,19 @@ function withSavepoint(database, operation) { database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); return result; } catch (error) { + let rollbackSucceeded = false; try { database.exec(`ROLLBACK TO SAVEPOINT ${SAVEPOINT_NAME}`); - } finally { - database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); + rollbackSucceeded = true; + } catch { + // Keep an unconfirmed failed savepoint open instead of risking a partial commit. + } + if (rollbackSucceeded) { + try { + database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); + } catch { + // Cleanup after a confirmed rollback must not replace the causal operation error. + } } throw error; } From 3f63e97cc730cd3874f3d78baf6ecadf50c38623 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 08:53:20 -0700 Subject: [PATCH 24/25] fix(stack): preserve parent changelog evidence --- CHANGELOG.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 530f13af..949f9345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 canonical public origin instead of request authority, rejected partial or ambiguous billing configuration at startup, and confined successful mock checkout to explicit development mode. +- Made live Stripe Checkout fail closed on network errors, provider non-2xx + responses, malformed JSON, missing hosted URLs, plaintext redirect URLs, and + URL credentials, returning a stable non-leaking HTTP 502 retry/operator action + instead of treating provider error documents as successful sessions. - Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or unexpanded placeholder values so production deployments fail closed. - Neutralized audit-log CSV formulas even when executable prefixes are hidden @@ -74,6 +78,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Switched the repository-local OpenCode development configuration from GitHub + Models to an NVIDIA NIM-only candidate set while preserving organization-level + review-workflow ownership in `ContextualWisdomLab/.github`. +- Production planning-analysis requests now combine tenant-bound, server-derived + contextual-orchestrator cost attribution with explicit `auto` orchestration + mode, delegating provider/model/topology policy to the shared service without + weakening ScopeWeave's authenticated, fail-closed transport or response + boundary controls. - Accepted XML whitespace before exact Microsoft Project element delimiters while preserving the linear, regex-free import scanner and rejecting attributes, longer names, non-XML whitespace, nested unmatched blocks, and @@ -119,4 +131,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) -- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. +- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하던 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. From 5bf3de2f8a8cc155948829b79619384a48e04008 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 10:58:57 -0700 Subject: [PATCH 25/25] fix(stack): preserve current provider cleanup contract in event ledger --- server/billing.mjs | 10 +++ tests/unit/billing-provider-boundary.test.mjs | 64 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/server/billing.mjs b/server/billing.mjs index c5849c8b..bf6ff2b6 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -173,6 +173,14 @@ async function readBoundedProviderJson(response) { } } +async function cancelUnreadProviderBody(response) { + try { + await response.body.cancel(); + } catch { + // Cleanup failure must never replace the stable provider failure returned below. + } +} + async function createStripeSessionWithFetch(secretKey, payload, idempotencyKey) { let response; try { @@ -200,11 +208,13 @@ async function createStripeSessionWithFetch(secretKey, payload, idempotencyKey) // no later caller silently creates a second Checkout Session with a fresh key. // Stripe's documented safest strategy for 4xx is a fresh idempotency key. const outcomeKnown = response.status < 500; + await cancelUnreadProviderBody(response); throw providerUnavailableFailure(outcomeKnown); } const mediaType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase(); if (mediaType !== 'application/json') { + await cancelUnreadProviderBody(response); throw providerInvalidResponseFailure(); } diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs index 1f4dd293..108a0e21 100644 --- a/tests/unit/billing-provider-boundary.test.mjs +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -246,6 +246,70 @@ test('Stripe server and malformed-success outcomes remain indeterminate while 4x }); }); +test('rejected Stripe responses cancel unread bodies while preserving retry-state semantics', async () => { + await withStripeEnv(async () => { + for (const scenario of [ + { status: 503, contentType: 'application/json', expectedCode: 'billing_provider_unavailable', closesAttempt: false }, + { status: 400, contentType: 'application/json', expectedCode: 'billing_provider_unavailable', closesAttempt: true }, + { status: 200, contentType: 'text/html', expectedCode: 'billing_provider_invalid_response', closesAttempt: false }, + ]) { + let cancelled = false; + const unreadBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('provider bytes that must not remain leased')); + }, + cancel() { + cancelled = true; + }, + }); + const attemptRepository = createAttemptRepository(); + globalThis.fetch = async () => new Response(unreadBody, { + status: scenario.status, + headers: { 'content-type': scenario.contentType }, + }); + + await expectProviderError( + () => liveCheckout(attemptRepository), + scenario.expectedCode, + ); + assert.equal(cancelled, true, `${scenario.expectedCode} cancels its unread response body`); + if (scenario.closesAttempt) { + assert.equal(attemptRepository.events.at(-1).type, 'failure'); + } else { + expectUnresolved(attemptRepository, 'indeterminate provider response keeps the durable retry identity'); + } + } + }); +}); + +test('response-body cleanup failure never replaces provider error or attempt outcome semantics', async () => { + await withStripeEnv(async () => { + let cancelCalls = 0; + const unreadBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('provider body')); + }, + cancel() { + cancelCalls += 1; + throw new Error('cleanup secret must not escape'); + }, + }); + const attemptRepository = createAttemptRepository(); + globalThis.fetch = async () => new Response(unreadBody, { + status: 400, + headers: { 'content-type': 'application/json' }, + }); + + const payload = await expectProviderError( + () => liveCheckout(attemptRepository), + 'billing_provider_unavailable', + ); + assert.equal(cancelCalls, 1); + assert.doesNotMatch(payload, /cleanup secret/); + assert.equal(attemptRepository.events.at(-1).type, 'failure'); + }); +}); + test('provider response declarations and streamed bytes are bounded before JSON parsing', async () => { await withStripeEnv(async () => { for (const declaredLength of ['not-a-number', '-1', String(providerResponseLimitBytes + 1)]) {