Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
dad8aed
test(billing): specify durable Stripe webhook event ledger
seonghobae Aug 16, 2026
09956c1
test(billing): register Stripe webhook event ledger regression
seonghobae Aug 16, 2026
8c7471b
feat(billing): persist verified Stripe webhook event evidence
seonghobae Aug 16, 2026
acee4a6
test(api): require durable Stripe webhook replay evidence
seonghobae Aug 16, 2026
48475f0
feat(billing): expose verified Stripe payload hash evidence
seonghobae Aug 16, 2026
0382017
feat(billing): bootstrap Stripe webhook event ledger
seonghobae Aug 16, 2026
a9546c2
feat(billing): expose bootstrap-injected webhook recorder
seonghobae Aug 16, 2026
a886807
feat(billing): bind webhook recorder at database bootstrap
seonghobae Aug 16, 2026
1fa143d
feat(billing): record authenticated Stripe event evidence
seonghobae Aug 16, 2026
1f063a4
test(api): exercise concurrent Stripe replay recording
seonghobae Aug 16, 2026
73d3030
test(billing): cover verified webhook recorder integration
seonghobae Aug 16, 2026
743c7c3
test(billing): normalize sqlite rows for hosted assertions
seonghobae Aug 16, 2026
86e334f
test(billing): normalize sqlite rows in webhook API assertions
seonghobae Aug 16, 2026
e1a403c
test(billing): reject malformed webhook request envelopes
seonghobae Aug 16, 2026
67dd5e7
fix(billing): validate Stripe request metadata envelope
seonghobae Aug 16, 2026
7e0b238
docs(billing): trace verified webhook event ledger
seonghobae Aug 16, 2026
98b11f5
docs(changelog): record verified webhook event evidence
seonghobae Aug 16, 2026
e0b01de
test(billing): require 3NF webhook delivery evidence
seonghobae Aug 16, 2026
8e923e6
fix(billing): normalize webhook delivery evidence to 3NF
seonghobae Aug 16, 2026
8256af2
test(billing): align webhook API evidence with 3NF ledger
seonghobae Aug 16, 2026
86aba59
docs(billing): trace 3NF webhook ledger repair
seonghobae Aug 16, 2026
0366056
merge(billing): reconcile webhook evidence ledger with current trust …
seonghobae Aug 16, 2026
b3cd066
test(billing): preserve webhook ledger causal failure on rollback cle…
seonghobae Aug 16, 2026
4f23e0b
fix(billing): fail closed on webhook ledger savepoint cleanup
seonghobae Aug 16, 2026
db6c401
merge(billing): inherit webhook parent rollback regression
seonghobae Aug 16, 2026
0c49dc7
Merge current webhook trust parent into verified event ledger
seonghobae Aug 16, 2026
8cc14ea
merge(billing): reconcile webhook event ledger with current trust parent
seonghobae Aug 16, 2026
dde711f
merge(billing): reconcile event ledger with exact webhook parent
seonghobae Aug 18, 2026
4580443
fix(stack): reconcile webhook ledger with current trust parent
seonghobae Aug 19, 2026
3f63e97
fix(stack): preserve parent changelog evidence
seonghobae Aug 19, 2026
efe7c3e
fix(stack): inherit webhook attribution repair in event ledger
seonghobae Aug 20, 2026
a7399a9
fix(stack): inherit current webhook boundary in event ledger
seonghobae Aug 20, 2026
beaaade
fix(stack): inherit Playwright-updated webhook boundary in event ledger
seonghobae Aug 20, 2026
7a8a181
chore(stack): reconcile event ledger with current webhook parent
seonghobae Aug 24, 2026
5bf3de2
fix(stack): preserve current provider cleanup contract in event ledger
seonghobae Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security

- Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected.
- 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, Stripe 5xx, malformed or
Expand Down
113 changes: 113 additions & 0 deletions docs/doctoring/stripe-webhook-event-ledger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# 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`.

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` 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.

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 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.

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 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` — 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 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.

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
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/orchestrator-attribution.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/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.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-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.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/toast-accessibility.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/application_routes.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/orchestrator.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.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-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.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/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.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-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.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 && node tests/unit/toast-accessibility.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/application_routes.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/orchestrator.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/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.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-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.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 tests/e2e/toast-accessibility.spec.js",
Expand Down
12 changes: 10 additions & 2 deletions server/db.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import {
createSqliteBillingCheckoutAttemptRepository,
installBillingCheckoutAttemptSchema,
} from './billing_checkout_attempt.mjs';
import {
configureStripeWebhookEventRecorder,
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');
Expand Down Expand Up @@ -181,9 +186,12 @@ 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);
configureStripeWebhookEventRecorder((evidence) => stripeWebhookEvents.recordVerifiedEvent(evidence));

// node:sqlite returns lastInsertRowid as number|bigint; normalize to Number.
export const rowid = (r) => Number(r.lastInsertRowid);
export const rowid = (r) => Number(r.lastInsertRowid);
Loading