diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 580d1d8..6a32400 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -29,6 +29,8 @@ jobs: - run: npm ci --ignore-scripts - run: npm test - run: npm run test:tooling + - name: Reproduce service composition from the source archive + run: bun composition/export.mjs "$RUNNER_TEMP/commerce-composition" - name: Verify documentation matches the recorded source run: bun export-docs.mjs "$RUNNER_TEMP/commerce-docs" - run: npm run verify:checkpoints diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d5632c9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# Example acceptance + +Read `README.md` for the runnable scope, `INTEGRATE.md` for product boundaries, +and `BUILD.md` for the implementation milestones and completion checks. + +Before finishing a change: + +- Follow the running example as a first-time reader on desktop and mobile. + Explain the result and the reader's next decision before implementation + detail. Keep a short record of the tasks attempted and points of confusion. +- Compare the affected responsibility with the corresponding IAPKit handler + and test linked from the [purchase guide](https://openiap.dev/commerce-protocol/getting-started). + Report the actual differences; the installed protocol remains authoritative. +- Replay changed implementation instructions in a clean project, using only + the documented inputs. Run the startup command, tests, and affected demos. + Retain failed attempts and verify the repaired behavior independently. +- Identify the tested source revision, commands, results, and fixture scope. + Do not infer real-store support, full profile conformance, or interoperability + between providers from a local fixture run. Label an AI reader simulation as + a simulation, not a human user study. + +Do not manufacture successful output or weaken acceptance to hide a failure. diff --git a/BUILD.md b/BUILD.md index b5884a0..f9c0629 100644 --- a/BUILD.md +++ b/BUILD.md @@ -32,6 +32,14 @@ Implement the backend in my project. Do not require an OpenIAP or IAPKit checkou and do not invent request fields, response shapes, role rules, or enum values. Follow my repository's instructions. Keep work uncommitted for review. +Read this example alongside [IAPKit's service source](https://github.com/hyodotdev/openiap/tree/main/packages/kit). +The [purchase walkthrough](https://openiap.dev/commerce-protocol/getting-started) +connects each step to both implementations and their checks. Use this example +to understand the small SQLite flow; use IAPKit to study real store adapters, +project authorization, account erasure, and the GraphQL adapter. Compare the +relevant code at each milestone without making either repository a runtime +dependency of the new project. + ## Start with a reviewable local result Use the stack already in my repository. If this is an empty project, choose a @@ -66,26 +74,55 @@ Build these milestones in order: events profile requires the public HTTPS destination protections in the spec. 6. **Recovery:** reopen the databases with pending deliveries, resume processing, and prove that neither ownership nor receiver deduplication disappears. +7. **Account deletion:** remove provider identity and recipient copies, retry the + same erasure after restart, and reject stale account requests and late events. After each milestone, run it. Show the command, actual API result, storage change, and passing assertions. Capture the working screen. Do not manufacture logs, screenshots, conformance counts, or claims about capabilities not tested. +## Complete the local implementation + +Before calling the result complete, implement erasure for the account lifecycle: +remove the user identity from provider records and event history, preserve other +users, and prevent a late retry from restoring erased recipient data. Keep +provider erasure separate from the recipient's responsibility for delivered +copies. Exercise erasure during delivery, on repetition, and after restart. + +Run the portable conformance runner for every selected profile and binding. +Do not finish with tests that expect known conformance failures. Keep every +previously exercised case in the completed run; changing declarations must not +hide a failure. Describe fixture-only capabilities explicitly, without implying +that a real store API or notification channel was connected. + +Copy only source and package metadata into an empty directory. Install, test, +and start it there, without the development database or output directories. +Check the visible app after purchase, cancellation, expiry, reload, and deletion. + ## Deliver - A runnable local backend and small inspection UI. - One command that verifies the demonstrated flow and exits nonzero on failure. - A short visual walkthrough, with real captured results for each milestone. - The exact scope and remaining work, including real store validation, - authentication, erasure, multi-tenant isolation, public HTTPS delivery, - operations, and full profile conformance. Keep the main explanation short; + real authentication, multi-tenant isolation, public HTTPS delivery, and + operations. Include the passing local conformance report and its fixture scope. Keep the main explanation short; link the specification for details. +## Match the selected store + +Read the store table in `INTEGRATE.md` before replacing the fixture. Exercise +all evidence shapes the provider advertises. Keep the app account distinct +from the Amazon/Meta store user, and reject a claim for someone else's store +account. Recheck ownership for Amazon/Horizon access; never invent subscription +or notification support to make their flow look like Apple/Google. Prove +negative rechecks, outages, conflicting bindings, erasure, and restart. + ## Then extend toward a production provider Ask me which real store, backend identity system, and deployment environment to -integrate before using credentials or external services. Implement the remaining -operations of each chosen profile, including erasure for `accountLifecycle`. +integrate before using credentials or external services. Replace fictional store and session adapters with the chosen integrations. +Keep the completed profile behavior and erasure checks passing. Run the portable conformance runner for every advertised binding and profile; also run real store sandbox, recovery, isolation, and load tests. Treat IAPKit as an implementation example, never as a replacement for the protocol's contract. diff --git a/INTEGRATE.md b/INTEGRATE.md index c497c13..122ee9f 100644 --- a/INTEGRATE.md +++ b/INTEGRATE.md @@ -23,7 +23,7 @@ Use your favorite package manager: `npm install`, `pnpm install`, `yarn install` or `bun install`. This example's runtime is Bun. The contract is `openiap-commerce-protocol` package 0.1.0, protocol 1.0; it does not require Bun. -- `npm run demo:bridge`: maps Apple/Google OpenIAP purchase fields into the +- `npm run demo:bridge`: maps Apple, Google, Amazon, and Horizon OpenIAP purchase fields into the installed verification schema; rejects missing or unsupported evidence. - `npm run demo:consumer`: sends signed lifecycle events to a SQLite inbox over HTTP, repeats deliveries, rejects tampering, and reopens persisted storage. @@ -36,6 +36,13 @@ gives the endpoint, configuration, and limits. ## Task for the AI +Use the [purchase walkthrough](https://openiap.dev/commerce-protocol/getting-started) +to compare this example with [IAPKit's service implementation](https://github.com/hyodotdev/openiap/tree/main/packages/kit) +at each step. This repository shows the small local implementation; IAPKit shows +store adapters, project credentials, erasure, and both API bindings. Follow the +linked handlers and checks for the responsibility you own. The installed +specification defines the required behavior; neither implementation changes it. + Inspect this repository's purchase flow and choose the role from the table. Install `openiap-commerce-protocol` with this repository's package manager. Read its `SPEC.md`, generated bindings and schemas, and signature/lifecycle @@ -55,7 +62,7 @@ for the app team. Follow these boundaries: UI, targeting, or product catalog API; document this host adapter explicitly. 2. **App connection:** the app uses its OpenIAP library to fetch products and request a store purchase. Its purchase callback sends evidence to its - authenticated backend. Use `client-bridge.mjs` there to map Apple/Google + authenticated backend. Use `client-bridge.mjs` there to map Apple, Google, Amazon, and Horizon purchase fields into a verification input; this does not authenticate the evidence. Keep server keys and user selection on that backend. Verify, bind under the ownership policy, read current access, fulfill durably, then finish @@ -76,9 +83,49 @@ for the app team. Follow these boundaries: The current client `verifyPurchaseWithProvider` helper supports IAPKit's own API. A different provider name or base URL does not turn it into this protocol. -Other providers connect through the app backend's REST or GraphQL calls. The -Apple/Google helper does not support Amazon or Horizon, whose protocol evidence -requires store-specific user identifiers distinct from the app's user ID. +Other providers connect through the app backend's REST or GraphQL calls. Amazon and Horizon require a store-specific user identifier distinct from the +app user ID. Pass it as `context.storeUserId` to the bridge after authenticating +the store account link. `startAppBackend` requires `resolveStoreUser` for these +stores and rejects evidence belonging to a different store account. Never +implement that callback by copying a user ID from the request body. + +## Select the store before implementing + +Follow the six-step purchase flow with your chosen store. Verification and +binding use these evidence shapes: + +| Store | Purchase evidence | IAPKit access path | +| --- | --- | --- | +| Apple | `apple.jws` from the store purchase | Bind the verified subscription; read its current state and listen for lifecycle events | +| Google | `google.purchaseToken` | Bind the verified subscription; read its current state and listen for lifecycle events | +| Amazon | `amazon.userId`, `amazon.receiptId`, optional `amazon.sandbox` | Bind the verified receipt; each entitlement read rechecks RVS | +| Meta Horizon | `horizon.userId`, `horizon.sku` | Bind the verified store-user/SKU pair; each entitlement read rechecks Meta | + +For Amazon and Horizon, use `entitlements.productIds` for access. IAPKit does +not invent a subscription record, expiry date, or lifecycle event for these +ownership checks. An empty `subscriptions` list can accompany owned products. +A negative store answer removes the product; a failed store call fails the +read. Decide caching and outage policy in the app backend. Reads currently +fail if an account has more than 20 linked Amazon/Horizon purchase rows. + +For Quest, verify Meta's user proof on your authenticated backend before linking +that Meta user to the app account. Follow the official +[Meta user verification guide](https://developers.meta.com/horizon/documentation/android-apps/ps-ownership/). +For Amazon, establish the store account association through your application's +trusted sign-in and ownership policy. Receipt possession alone does not prove +which app account may claim it. The runnable comparison uses explicit fictional +session links; it does not implement your authentication provider. + +Keep consumable fulfillment separate: record each granted unit durably and +idempotently before finishing/consuming. A verified SKU is not a new quantity +to credit on every read. The protocol walkthrough demonstrates Premium access; +it does not implement a wallet or sell a Nami paywall. + +For IAPKit setup, configure Apple bundle/App ID and Server API signing key, +Google package and service account, Meta App ID/secret, or Amazon RVS shared +secret in the project. Keep secrets on the server. Enable Amazon sandbox only +for App Tester evidence. The local comparison requires none of these real +credentials; its external store responses are fixtures. ## Deliver and prove the connection diff --git a/README.md b/README.md index ec1ec9a..c806211 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # OpenIAP Commerce Protocol example -A runnable purchase-to-access backend, built and reviewed with AI in six +A runnable purchase-to-access backend, built and reviewed with AI in seven milestones. Follow a purchase through verification, ownership, access, and -signed event delivery. Inspect the actual HTTP responses and database changes. +signed event delivery, and account deletion. Inspect the actual HTTP responses and database changes. The backend uses the published **`openiap-commerce-protocol`** package. HTTP, SQLite, and webhook signatures run locally; the store, users, and clock are @@ -23,7 +23,7 @@ npm start ``` Open **http://127.0.0.1:5181**, then click **Run step 1 →** and continue through -step 6. Each step changes real local state. The dashboard shows purchases, +step 7. Each step changes real local state. The dashboard shows purchases, current access, delivery attempts, and expandable request/response details. No store account, API key, OpenIAP checkout, or IAPKit account is required. Modern Yarn uses the included `node_modules` linker. @@ -44,6 +44,7 @@ and the [complete build history](https://github.com/hyodotdev/openiap-commerce-p | 4. Cancel | Turn off renewal; queue an event | Paid access remains until expiry | | 5. Deliver | Sign events; retry a failed receiver | A repeated delivery has one inbox effect | | 6. Expire | Advance the clock; reopen SQLite | Access closes; ownership and delivery records remain | +| 7. Erase | Remove provider identity and receiver copies | Repeated deletion and late deliveries cannot restore the user | Restarting `npm start` creates a fresh temporary database, so you can replay the walkthrough. Step 6 reopens the existing databases **inside the running process**; @@ -59,8 +60,10 @@ If port 5181 is occupied, run `COMMERCE_LAB_PORT=5183 npm start`. | Data / automation | [Event receiver guide](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/receiver.md) | A ready signed-event receiver with a durable inbox | | Integrated platform | [Integration brief](INTEGRATE.md) | How the roles compose without splitting account authority | -`client-bridge.mjs` maps Apple/Google OpenIAP purchase fields into the installed +`client-bridge.mjs` maps Apple, Google, Amazon, and Horizon OpenIAP purchase fields into the installed verification schema **on the app backend**. Run `npm run demo:bridge` to check it. +`npm test` also exercises all four fixture shapes through verification, binding, +access and erasure. Amazon/Horizon cases include negative rechecks and outages. It does not perform a mobile purchase or authenticate store evidence. The current client `verifyPurchaseWithProvider` helper uses IAPKit's own API; other providers connect through the app's authenticated backend. @@ -109,9 +112,32 @@ and Google Chrome. The [recording guide](https://github.com/hyodotdev/openiap-co explains how to preserve a checkpoint and export evidence. GitHub CI runs the runtime, tooling, archive, and documentation-export checks. +## Account deletion + +Step 7 runs `eraseUser` using server credentials. The provider removes identity +from purchases and removes identity-bearing event records in one transaction. +A repeated request returns the same completed job, including after restart. + +The app owns already-delivered copies: it erases its receiver inbox and retains +a keyed deletion marker so late signed events are acknowledged without storing +the deleted identity. The example also refuses rebinding erased evidence. +This is a local ownership policy; the protocol does not cancel the store subscription. +Database backups and the app’s own account records remain the operator’s responsibility. + +Run `bun verify-erasure.mjs` to exercise deletion while a delivery is in flight, +late lifecycle events, repeated requests, and storage reopening. + +## Replace the example with IAPKit + +The OpenIAP checkout includes `packages/kit/scripts/docs/run-commerce-interop.mjs`. +It starts IAPKit with an isolated local Convex deployment and keeps one app +backend and receiver running while switching the commerce provider. See the +[composition guide](https://openiap.dev/commerce-protocol/ecosystem#composition-proof) +for the executed report, source, and command. No IAPKit account or store keys are needed. + ## What remains for production -Real store validation and sandbox purchases, login, user erasure, tenant +Real store validation and sandbox purchases, login, tenant isolation, GraphQL, public HTTPS delivery protections, and operational recovery are not implemented here. The backend advertises **no complete profiles**. Schema checks and the local walkthrough do not establish profile conformance. diff --git a/ai-task.md b/ai-task.md index 7a2e80f..e6aa254 100644 --- a/ai-task.md +++ b/ai-task.md @@ -1,63 +1,13 @@ -# Review the completed backend - -Apply the Fable 5.1 max CLI review to the final checkpoint. Add the missing -first-binding grant event in the same transaction as ownership, reject a -premature expiry without consuming its observation, and verify both rollback -and expiry boundaries. Correct test labels to describe what they exercise. - -Repair patch generation without rewriting historical source or screenshots. -Build patches from the preceding archived source and verify their hashes. -Install and test each archive outside the monorepo with npm. Capture the revised -final screen on desktop and mobile, then export only matching source evidence. - -Keep the original six checkpoints as history. Explain their incomplete discovery -and missing grant behavior; do not describe them as conformant providers. Keep -all changes uncommitted for maintainer review. - -Apply the second review: retain gate delivery state for delayed expiry, -retain store occurrence on delayed binding, preserve consecutive failure logs, -and state actual package contents and runtime requirements. Add a ready-to-run -generic event receiver for an existing backend, reusing the same receiver -handler. Demonstrate signed lifecycle ingestion, duplicate delivery, tampering, -and persisted inbox recovery over real local HTTP. Keep all samples fictional. - -Add composable integration roles: experience, commerce, and data. Ship a short -AI integration brief and a backend helper that maps Apple/Google OpenIAP -purchase fields into the installed verification schema. Test invalid inputs -and exclude client-supplied identity. Keep paywall UI APIs product-specific, -current IAPKit-only client helpers explicit, and store/device proof separate -from local fixtures. Reuse the existing consumer and contract validators. - -The first bridge test failed because store evidence was nested under an extra -`evidence` key. Keep the failure output, use the installed input schema's -top-level `apple`/`google` members, and rerun that boundary check. - -Apply the third CLI review. Make the integration brief reachable from the docs -site with absolute setup, source, and build-brief links. Accept signed webhook -delivery behind a reverse proxy that preserves the public Host header. Keep -unfinished captures from blocking completed history, retain equal-time fixture -transitions, record the observed duplicate response, and configure Yarn's -node-modules linker. Preserve the proxy failure and rerun the checks. - -Prepare the standalone example for its first public commit. Rewrite the README -around clone, run, inspect, choose a role, and verify. Put receiver and capture -setup in repository documentation so the example works before the docs site is -deployed. Preserve every earlier archive. Record this documentation revision, -verify all source archives again, and export a stable current-source download. -Add CI that tests runtime, tooling, archives and the documentation export. This -revision is a local publication review, not another completed external review. - -Fix the first Linux CI failure without rewriting historical recordings. macOS -AppleDouble metadata must not count as source. Exclude it on extraction, omit it -from new archives, and add a portable extraction regression test. Capture the -corrected tooling, verify every source revision, and rerun GitHub CI. - -Correct the final CLI review finding: exercise cancellation at the expiry -observation timestamp so the check reaches the expired-state guard. Preserve -the earlier capture, record this revision, and verify its archive and patch. - -Apply the Codex review: authenticate webhook body bytes before UTF-8 decoding. -Reject altered UTF-8 and inserted BOM bytes with an unchanged signature. Reject -authentically signed malformed UTF-8 before storage, and accept correctly signed -Unicode and BOM bodies. Keep the reproduced failure, preserve old checkpoints, -then capture and verify the corrected revision. +# Cover the four IAPKit store boundaries + +Map Apple, Google, Amazon and Meta Horizon evidence into the installed protocol. +Keep store identity distinct from the app session; Amazon and Horizon claims +must pass the host's authenticated store-account link. Add matching fixture +provider paths and execute ownership, rejection, outage and erasure cases. +Compare the same application backend with IAPKit's actual handlers and local +Convex storage. Mark external store calls as fixtures and do not invent a +subscription lifecycle or a consumable wallet from a SKU ownership result. + +Run npm test and the provider comparison, capture the current source, and verify +it from an empty directory. Preserve the previous checkpoint. Keep all changes +uncommitted for maintainer review. diff --git a/capture.mjs b/capture.mjs index 53904d7..9ff8993 100644 --- a/capture.mjs +++ b/capture.mjs @@ -9,7 +9,7 @@ import { rmSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; import { chromium } from "@playwright/test"; import { @@ -46,8 +46,10 @@ let lab, browser; try { mkdirSync(current); mkdirSync(previous); - for (const name of SOURCE_FILES) + for (const name of SOURCE_FILES) { + mkdirSync(dirname(join(current, name)), { recursive: true }); cpSync(join(root, name), join(current, name)); + } const sourceHashes = hashes(current); if (checkpoint.previous) { assert(/^\d\d-[\w-]+$/.test(checkpoint.previous)); diff --git a/checkpoint-tools.mjs b/checkpoint-tools.mjs index 06b5f3b..8ef96bc 100644 --- a/checkpoint-tools.mjs +++ b/checkpoint-tools.mjs @@ -8,6 +8,7 @@ import { mkdtempSync, readFileSync, readdirSync, + statSync, rmSync, writeFileSync, } from "node:fs"; @@ -18,6 +19,7 @@ export const SOURCE_FILES = [ ".gitignore", ".yarnrc.yml", "LICENSE", + "AGENTS.md", "README.md", "BUILD.md", "INTEGRATE.md", @@ -27,6 +29,9 @@ export const SOURCE_FILES = [ "package-lock.json", "contract.mjs", "provider.mjs", + "erasure.mjs", + "verify-erasure.mjs", + "verify-stores.mjs", "webhooks.mjs", "consumer.mjs", "client-bridge.mjs", @@ -34,6 +39,16 @@ export const SOURCE_FILES = [ "server.mjs", "verify.mjs", "dashboard.html", + "composition/README.md", + "composition/app-backend.mjs", + "composition/app-backend.test.mjs", + "composition/receiver.test.mjs", + "composition/commerce-client.mjs", + "composition/export.mjs", + "composition/memory-provider.mjs", + "composition/purchase-flow.mjs", + "composition/run.mjs", + "capture.mjs", "export-docs.mjs", "checkpoint-tools.mjs", @@ -42,12 +57,18 @@ export const SOURCE_FILES = [ ]; export const sha256 = (file) => createHash("sha256").update(readFileSync(file)).digest("hex"); -export const hashes = (directory) => - Object.fromEntries( - readdirSync(directory) - .sort() - .map((name) => [name, sha256(join(directory, name))]), - ); +export function hashes(directory) { + const entries = []; + function visit(relative) { + for (const name of readdirSync(join(directory, relative)).sort()) { + const file = join(relative, name); + if (statSync(join(directory, file)).isDirectory()) visit(file); + else entries.push([file, sha256(join(directory, file))]); + } + } + visit(""); + return Object.fromEntries(entries); +} export function sanitize(text) { const roots = [process.cwd(), import.meta.dir, homedir()] .filter(Boolean) @@ -133,7 +154,7 @@ export function createPatch(before, after) { } } export function readRecords(directory) { - return readdirSync(directory) + const records = readdirSync(directory) .filter( (name) => /^\d\d-[\w-]+$/.test(name) && @@ -143,4 +164,25 @@ export function readRecords(directory) { .map((name) => JSON.parse(readFileSync(join(directory, name, "run.json"), "utf8")), ); + const byId = new Map(records.map((record) => [record.id, record])); + const ordered = [], + visiting = new Set(), + visited = new Set(); + function visit(record) { + if (visited.has(record.id)) return; + assert(!visiting.has(record.id), `Checkpoint cycle: ${record.id}`); + visiting.add(record.id); + if (record.previous) { + assert( + byId.has(record.previous), + `Missing predecessor: ${record.previous}`, + ); + visit(byId.get(record.previous)); + } + visiting.delete(record.id); + visited.add(record.id); + ordered.push(record); + } + records.forEach(visit); + return ordered; } diff --git a/checkpoint-tools.test.mjs b/checkpoint-tools.test.mjs index e807fae..a45e857 100644 --- a/checkpoint-tools.test.mjs +++ b/checkpoint-tools.test.mjs @@ -108,3 +108,29 @@ test("unfinished captures do not hide completed records", () => { rmSync(temp, { recursive: true, force: true }); } }); + +test("checkpoint replay follows predecessors instead of directory name order", () => { + const temp = mkdtempSync(join(tmpdir(), "commerce-capture-test-")); + const records = [ + { id: "07-reviewed", previous: "01-start" }, + { id: "07-interoperable", previous: "07-reviewed" }, + { id: "01-start" }, + ]; + try { + for (const record of records) { + mkdirSync(join(temp, record.id)); + writeFileSync(join(temp, record.id, "run.json"), JSON.stringify(record)); + } + assert.deepEqual( + readRecords(temp).map((record) => record.id), + ["01-start", "07-reviewed", "07-interoperable"], + ); + writeFileSync( + join(temp, "01-start/run.json"), + JSON.stringify({ id: "01-start", previous: "07-interoperable" }), + ); + assert.throws(() => readRecords(temp), /Checkpoint cycle/); + } finally { + rmSync(temp, { recursive: true, force: true }); + } +}); diff --git a/checkpoint.json b/checkpoint.json index 5ee086d..9344586 100644 --- a/checkpoint.json +++ b/checkpoint.json @@ -1,8 +1,8 @@ { - "step": 6, - "id": "06-recover-reviewed-8", - "title": "Expire access and restart", - "built": "Exact-byte webhook authentication with Unicode regression checks", - "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", - "previous": "06-recover-reviewed-7" + "step": 7, + "id": "07-account-erasure-interoperable-6", + "title": "Delete the account", + "built": "Apple, Google, Amazon and Horizon evidence, ownership checks and account erasure", + "result": "The same account flow covers four store evidence shapes; Amazon and Horizon access is rechecked.", + "previous": "07-account-erasure-interoperable-5" } diff --git a/client-bridge.mjs b/client-bridge.mjs index 1d5d64a..248d314 100644 --- a/client-bridge.mjs +++ b/client-bridge.mjs @@ -2,18 +2,43 @@ import assert from "node:assert/strict"; import { operation, validate } from "./contract.mjs"; // Run on the app backend; the provider still authenticates the store evidence. -export function toVerifyPurchaseInput(purchase) { +export function toVerifyPurchaseInput(purchase, context = {}) { const { store, purchaseToken } = purchase ?? {}; - if (!["apple", "google"].includes(store)) - throw new Error("This adapter supports Apple and Google purchases only"); - if (typeof purchaseToken !== "string" || !purchaseToken.trim()) - throw new Error("Purchase evidence is required"); - const input = { - store, - ...(store === "apple" - ? { apple: { jws: purchaseToken } } - : { google: { purchaseToken } }), + let input; + const required = (value) => { + if (typeof value !== "string" || !value.trim()) + throw new Error("Purchase evidence is required"); + return value; }; + switch (store) { + case "apple": + input = { store, apple: { jws: required(purchaseToken) } }; + break; + case "google": + input = { store, google: { purchaseToken: required(purchaseToken) } }; + break; + case "amazon": + input = { + store, + amazon: { + userId: required(context.storeUserId), + receiptId: required(purchaseToken), + ...(context.amazonSandbox === true ? { sandbox: true } : {}), + }, + }; + break; + case "horizon": + input = { + store, + horizon: { + userId: required(context.storeUserId), + sku: required(purchase.productId), + }, + }; + break; + default: + throw new Error("Unsupported purchase store"); + } if (!validate(operation("verifyPurchase").input, input)) throw new Error("Purchase evidence does not match the protocol input"); return input; @@ -39,15 +64,50 @@ export function runBridgeDemo() { assert(validate(operation("verifyPurchase").input, input)); checks.push(`${store}: matches the installed verification input schema`); } + for (const store of ["amazon", "horizon"]) { + const input = toVerifyPurchaseInput( + { + store, + purchaseToken: "receipt-1", + productId: "premium.monthly", + userId: "untrusted-app-user", + }, + { storeUserId: "authenticated-store-user", amazonSandbox: true }, + ); + assert.deepEqual( + input, + store === "amazon" + ? { + store, + amazon: { + userId: "authenticated-store-user", + receiptId: "receipt-1", + sandbox: true, + }, + } + : { + store, + horizon: { + userId: "authenticated-store-user", + sku: "premium.monthly", + }, + }, + ); + checks.push( + `${store}: uses server-selected store identity, distinct from the app user`, + ); + assert(validate(operation("verifyPurchase").input, input)); + checks.push(`${store}: matches the installed verification input schema`); + } for (const [label, purchase] of [ ["missing purchase", null], ["unknown store", { store: "unknown", purchaseToken: "fictional" }], [ - "Amazon needs its own adapter", + "Amazon requires its authenticated store user", { store: "amazon", purchaseToken: "fictional" }, ], [ - "Horizon needs its own adapter", + "Horizon requires its authenticated store user", { store: "horizon", purchaseToken: "fictional" }, ], ["missing evidence", { store: "apple" }], diff --git a/composition/README.md b/composition/README.md new file mode 100644 index 0000000..f0da5f6 --- /dev/null +++ b/composition/README.md @@ -0,0 +1,95 @@ +# Compose services and inspect the boundary + +Run the same app-backend client against two separately implemented fixture +providers, and send both providers' events to the same receiver implementation. +Only connection configuration changes. The runner records the results and source +hashes, and fails when a compared outcome differs. + +With Bun 1.3.13 and Node.js 24 / npm installed, run from the repository root +(or the extracted source archive): + +```sh +npm ci --ignore-scripts +bun composition/run.mjs +bun composition/run.mjs --record composition-report.json +``` + +No environment variables, `.env` file, store accounts, or IAPKit credentials are +required. The runner creates its fixture credentials, webhook keys, temporary +databases, and local HTTP ports, then cleans up after itself. Installing +dependencies needs registry access; the composition run uses loopback HTTP only. + +The seven-step dashboard, including account deletion, remains available through `npm start` at +`http://127.0.0.1:5181`. Set `COMMERCE_LAB_PORT` only to change that port. + +## Follow the code + +| Part | Source | Responsibility | +| --------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------- | +| App backend | `composition/commerce-client.mjs` | Validate calls, verify evidence, bind to the authenticated user, read access | +| Host/paywall callback | `composition/purchase-flow.mjs` | Purchase → backend fulfillment → finish; display pending, canceled, failed, or fulfilled | +| Provider A | `provider.mjs` | Existing SQLite purchase and ownership implementation | +| Provider B | `composition/memory-provider.mjs` | Separate Map-based handlers and event signer; shares contract metadata, no Provider A business logic | +| Event consumer | `webhooks.mjs` | Authenticate raw bytes and persist one inbox effect per event | +| Reproduction | `composition/run.mjs` | Start isolated HTTP listeners, run both configurations, compare results and failure cases | + +The app backend holds the server credential and selects `userId` from its own +authenticated session. The host adapter receives a fulfillment callback; never +ship `commerce-client.mjs` or its credential to an app. Real SDK pending results +resume through the app's purchase-update listener. This fixture exercises the +callback boundary, not a particular paywall SDK or mobile purchase runtime. + +## What the run demonstrates + +Both configurations save verification without granting access, bind only once, +reject another owner, preserve access after cancellation, and close it exactly +at expiry even before a notification. Both emit signed events; a 503 retries, +redelivery has one inbox effect, altered bodies and another emitter's key fail, +and an optional `extensions["partner.segment"]` string survives storage without editing the +consumer. The consumer also rejects a malformed successful API response. + +Each emitter/project has its own configured endpoint, secret, and inbox +database in this baseline run. For the IAPKit replacement run, `startAppBackend` +keeps one application endpoint and one receiver alive. Configure that receiver +with named emitters and separate signing keys bound to each project ID; equal +event IDs from different providers then remain distinct in the same inbox. +This does not identify duplicate real-world facts across a provider cutover. + +## Make and verify one change + +Change the `extensions["partner.segment"]` string in `composition/run.mjs`, rerun +the command, and inspect its storage check. The purchase flow, client, and +receiver modules stay the same. For a provider implementation +change, retain the expected access results and rerun against both providers; +do not edit the expected outcome merely to make an incompatible result pass. + +`bun composition/export.mjs ` also runs a negative control +inside a temporary copy: changing the SQLite access deadline from `<` to `<=` +must fail the deadline check. It leaves the original source unchanged. + +## Bring another implementation + +Use `createCommerceClient({ baseUrl, credential })` for a disposable test account. +Supply a provider-owned setup/transition adapter in the runner for its fixture +purchase, cancellation, and expiry; those controls are not protocol operations. +Keep the client, receiver, and expected outcomes unchanged. Record the exact +revision, configuration fields changed (never secret values), commands, result +report, and any required code changes. A changed adapter is evidence of work +needed, not a reason to hide the change. + +Run the published protocol conformance runner separately for every claimed +profile/binding. This demo advertises no complete profiles. External teams can +publish their own report without an OpenIAP account or hosted checker. + +## Scope + +The services communicate over real loopback HTTP in one Bun process. Provider A +uses SQLite; Provider B keeps state in memory. Store evidence, users, clock, and +purchase callbacks are fixtures. Both implementations were authored within this +project: this is reproducible implementation evidence, not independent company +validation. It proves neither real store verification nor production reliability. + +Both providers start empty. Moving ownership/history, rotating credentials, +cutover overlap, process-crash recovery, and a real SDK purchase need separate +tests. The baseline receiver stores events; it does not compute a revenue ledger +or grant access from webhook arrival. diff --git a/composition/app-backend.mjs b/composition/app-backend.mjs new file mode 100644 index 0000000..5803dbb --- /dev/null +++ b/composition/app-backend.mjs @@ -0,0 +1,130 @@ +import { Database } from "bun:sqlite"; +import { createCommerceClient } from "./commerce-client.mjs"; +import { createErasureLedger } from "../erasure.mjs"; + +// resolveSession is the host app's authentication boundary, supplied by the caller. +export function startAppBackend({ + path, + providers, + receiver, + resolveSession, + resolveStoreUser, +}) { + const db = new Database(path, { create: true }); + db.exec( + "CREATE TABLE IF NOT EXISTS erasure_requests (user_id TEXT, provider TEXT, PRIMARY KEY(user_id, provider))", + ); + const erased = createErasureLedger(db); + const clients = Object.fromEntries( + Object.entries(providers).map(([name, config]) => [ + name, + createCommerceClient(config), + ]), + ); + const inFlight = new Map(); + let selected = Object.keys(clients)[0]; + async function drainErasure() { + for (const row of db.query("SELECT * FROM erasure_requests").all()) { + try { + receiver.eraseUser(row.user_id); + await Promise.allSettled([...(inFlight.get(row.user_id) ?? [])]); + const result = await clients[row.provider].call("eraseUser", { + userId: row.user_id, + }); + if (result.accepted && result.status === "completed") + db.query( + "DELETE FROM erasure_requests WHERE user_id = ? AND provider = ?", + ).run(row.user_id, row.provider); + } catch { + /* The durable request is retried by the app's worker. */ + } + } + return db.query("SELECT count(*) AS count FROM erasure_requests").get() + .count; + } + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + maxRequestBodySize: 32768, + async fetch(request) { + const userId = await resolveSession(request); + if (!userId || erased.has(userId)) + return new Response("Unauthenticated", { status: 401 }); + const url = new URL(request.url); + try { + if (url.pathname === "/purchase" && request.method === "POST") { + const input = await request.json(); + if (erased.has(userId)) + return new Response("Unauthenticated", { status: 401 }); + if (input.store === "amazon" || input.store === "horizon") { + const storeUser = await resolveStoreUser?.(request, input.store); + if (!storeUser || input[input.store]?.userId !== storeUser) + return new Response( + "Store account is not linked to this session", + { status: 403 }, + ); + } + if (erased.has(userId)) + return new Response("Unauthenticated", { status: 401 }); + const work = clients[selected].fulfill(input, { + userId, + productId: "premium.monthly", + }); + const pending = inFlight.get(userId) ?? new Set(); + pending.add(work); + inFlight.set(userId, pending); + let result; + try { + result = await work; + } finally { + pending.delete(work); + if (!pending.size) inFlight.delete(userId); + } + // A deletion can race the upstream calls; never return access afterwards. + return Response.json(erased.has(userId) ? { access: false } : result); + } + if (url.pathname === "/access" && request.method === "GET") { + const result = await clients[selected].call("entitlements", { + userId, + }); + return erased.has(userId) + ? new Response("Unauthenticated", { status: 401 }) + : Response.json(result); + } + if (url.pathname === "/account" && request.method === "DELETE") { + db.transaction(() => { + erased.remember(userId); + for (const name of Object.keys(clients)) + db.query( + "INSERT OR IGNORE INTO erasure_requests VALUES (?,?)", + ).run(userId, name); + })(); + receiver.eraseUser(userId); + const pending = await drainErasure(); + return Response.json({ + accepted: true, + status: pending ? "queued" : "completed", + }); + } + return new Response("Not found", { status: 404 }); + } catch { + return Response.json( + { error: "Commerce provider unavailable; retry the request." }, + { status: 503 }, + ); + } + }, + }); + return { + url: `http://127.0.0.1:${server.port}`, + select(name) { + if (!clients[name]) throw new Error("Unknown provider"); + selected = name; + }, + drainErasure, + async close() { + await server.stop(true); + db.close(); + }, + }; +} diff --git a/composition/app-backend.test.mjs b/composition/app-backend.test.mjs new file mode 100644 index 0000000..dc43f6c --- /dev/null +++ b/composition/app-backend.test.mjs @@ -0,0 +1,103 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createProvider, FIXTURE, CREDENTIALS } from "../provider.mjs"; +import { startConsumer } from "../consumer.mjs"; +import { startAppBackend } from "./app-backend.mjs"; + +test("account deletion waits for in-flight fulfillment and retries provider erasure after app restart", async () => { + const directory = mkdtempSync(join(tmpdir(), "commerce-app-erasure-")); + const provider = createProvider( + join(directory, "provider.sqlite"), + () => FIXTURE.startsAt, + ); + let releaseBind, signalBind; + const binding = new Promise((resolve) => { + signalBind = resolve; + }); + const gate = new Promise((resolve) => { + releaseBind = resolve; + }); + let rejectErase = true, + eraseCalls = 0; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + if (new URL(request.url).pathname.endsWith("/bind")) { + signalBind(); + await gate; + } + if (new URL(request.url).pathname.endsWith("/erase")) { + eraseCalls++; + if (rejectErase) return new Response(null, { status: 503 }); + } + return provider.fetch(request); + }, + }); + const receiver = startConsumer({ + path: join(directory, "receiver.sqlite"), + secret: "fixture-key", + }); + const options = { + path: join(directory, "app.sqlite"), + providers: { + example: { + baseUrl: `http://127.0.0.1:${server.port}`, + credential: CREDENTIALS.server, + }, + }, + receiver, + resolveSession: () => FIXTURE.userId, + }; + let app = startAppBackend(options); + try { + const purchase = fetch(app.url + "/purchase", { + method: "POST", + body: JSON.stringify({ + store: FIXTURE.store, + evidence: FIXTURE.evidence, + }), + }); + await binding; + const deletion = fetch(app.url + "/account", { method: "DELETE" }); + for (let i = 0; i < 50; i++) { + const response = await fetch(app.url + "/access"); + if (response.status === 401) break; + await Bun.sleep(10); + } + expect((await fetch(app.url + "/access")).status).toBe(401); + let workerFinished = false; + const background = app.drainErasure().then((pending) => { + workerFinished = true; + return pending; + }); + await Bun.sleep(20); + expect(workerFinished).toBe(false); + expect(eraseCalls).toBe(0); + releaseBind(); + expect(await (await purchase).json()).toEqual({ access: false }); + expect(await (await deletion).json()).toEqual({ + accepted: true, + status: "queued", + }); + expect(await background).toBe(1); + await app.close(); + app = startAppBackend(options); + expect( + (await fetch(app.url + "/purchase", { method: "POST", body: "{}" })) + .status, + ).toBe(401); + rejectErase = false; + expect(await app.drainErasure()).toBe(0); + expect(provider.inspect().purchases[0].userId).toBeNull(); + } finally { + releaseBind(); + await app.close(); + await receiver.close(); + await server.stop(true); + provider.close(); + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/composition/commerce-client.mjs b/composition/commerce-client.mjs new file mode 100644 index 0000000..7b5820f --- /dev/null +++ b/composition/commerce-client.mjs @@ -0,0 +1,46 @@ +import { operation, validate } from "../contract.mjs"; + +// This module runs on the authenticated app backend, which owns the credential. +export function createCommerceClient({ baseUrl, credential }) { + async function call(name, input) { + const spec = operation(name); + if (!spec) throw new Error("Unknown operation"); + if (spec.input && !validate(spec.input, input)) + throw new Error("Invalid operation input"); + const url = new URL(spec.path, baseUrl); + if (spec.method === "GET" && input) + for (const [key, value] of Object.entries(input)) + url.searchParams.set(key, value); + const response = await fetch(url, { + method: spec.method, + headers: { + "content-type": "application/json", + ...(spec.auth === "none" ? {} : { authorization: credential }), + }, + ...(spec.method === "POST" ? { body: JSON.stringify(input) } : {}), + redirect: "error", + signal: AbortSignal.timeout(5000), + }); + const result = await response.json(); + if (response.status !== spec.successStatus) + throw new Error("Commerce operation failed"); + if (!validate(spec.result, result)) + throw new Error("Invalid operation result"); + return result; + } + + async function fulfill(input, { userId, productId }) { + const evidence = { ...input }; + delete evidence.userId; + const verdict = await call("verifyPurchase", evidence); + if (!verdict.isValid) throw new Error("Purchase was not accepted"); + const binding = await call("bindPurchase", { ...evidence, userId }); + if (!binding.bound) throw new Error("Purchase belongs to another user"); + const access = await call("entitlements", { userId }); + if (!access.productIds.includes(productId)) + throw new Error("Requested product is not accessible"); + return access; + } + + return { call, fulfill }; +} diff --git a/composition/export.mjs b/composition/export.mjs new file mode 100644 index 0000000..cfb9c06 --- /dev/null +++ b/composition/export.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { SOURCE_FILES } from "../checkpoint-tools.mjs"; +import { COMPOSITION_SOURCES, runComposition } from "./run.mjs"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +assert( + process.argv[2], + "Usage: bun composition/export.mjs ", +); +const output = resolve(process.argv[2]); +const temp = mkdtempSync(join(tmpdir(), "commerce-composition-export-")); +const source = join(temp, "source"), + replay = join(temp, "replay"); +const run = (command, args, cwd) => { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + maxBuffer: 5 * 1024 * 1024, + env: { ...process.env, COPYFILE_DISABLE: "1" }, + }); + assert.equal( + result.status, + 0, + `${command} failed: ${result.stderr}\n${result.stdout}`, + ); +}; +try { + const report = await runComposition(); + const names = [ + ...new Set([ + ...SOURCE_FILES, + ...COMPOSITION_SOURCES, + "composition/export.mjs", + ]), + ].sort(); + for (const name of names) { + mkdirSync(dirname(join(source, name)), { recursive: true }); + cpSync(join(root, name), join(source, name)); + } + mkdirSync(output, { recursive: true }); + const archive = join(output, "source.tar.gz"); + run("tar", ["-czf", archive, ...names], source); + mkdirSync(replay); + run("tar", ["-xzf", archive, "-C", replay], temp); + run("npm", ["ci", "--ignore-scripts"], replay); + run( + process.execPath, + ["composition/run.mjs", "--record", "replay.json"], + replay, + ); + const replayed = JSON.parse( + readFileSync(join(replay, "replay.json"), "utf8"), + ); + assert.deepEqual(replayed.sourceHashes, report.sourceHashes); + assert.deepEqual(replayed.results, report.results); + assert.deepEqual(replayed.checks, report.checks); + const providerPath = join(replay, "provider.mjs"); + const original = readFileSync(providerPath, "utf8"); + assert(original.includes("now < expiresAt")); + writeFileSync( + providerPath, + original.replace("now < expiresAt", "now <= expiresAt"), + ); + const negative = spawnSync(process.execPath, ["composition/run.mjs"], { + cwd: replay, + encoding: "utf8", + maxBuffer: 1024 * 1024, + }); + const rejectedCheck = + "sqlite: read closes access at the deadline before a notification"; + assert.equal(negative.status, 1); + assert( + (negative.stderr + negative.stdout).includes(rejectedCheck), + "Negative control must fail the intended assertion", + ); + report.negativeControl = { + change: + "Replace now < expiresAt with now <= expiresAt in a temporary provider copy", + detected: true, + rejectedCheck, + }; + report.archiveVerification = { + command: "npm ci --ignore-scripts && bun composition/run.mjs", + sameSourceAndResults: true, + sha256: createHash("sha256").update(readFileSync(archive)).digest("hex"), + }; + writeFileSync( + join(output, "run.json"), + JSON.stringify(report, null, 2) + "\n", + ); + writeFileSync( + join(output, "source.json"), + JSON.stringify( + Object.fromEntries( + [ + "composition/commerce-client.mjs", + "composition/purchase-flow.mjs", + "composition/memory-provider.mjs", + "composition/run.mjs", + ].map((name) => [name, readFileSync(join(source, name), "utf8")]), + ), + null, + 2, + ) + "\n", + ); + cpSync(join(root, "composition/README.md"), join(output, "README.md")); + console.log( + `Exported ${report.checks.length} composition checks with a clean-install archive replay.`, + ); +} finally { + rmSync(temp, { recursive: true, force: true }); +} diff --git a/composition/memory-provider.mjs b/composition/memory-provider.mjs new file mode 100644 index 0000000..dff6b8a --- /dev/null +++ b/composition/memory-provider.mjs @@ -0,0 +1,266 @@ +import { createHmac, randomUUID } from "node:crypto"; +import { + COMMERCE_EVENT_VERSION, + HTTP_BINDING, + WEBHOOK, + providerCapabilitiesSchema, +} from "openiap-commerce-protocol"; +import { operation, protocolError, validate } from "../contract.mjs"; + +// A second fixture implementation; it shares contract metadata, not SQLite logic. +export function createMemoryProvider({ fixture, credential, now }) { + const purchases = new Map(); + const outbox = []; + const supported = new Set([ + "initialValidation", + "subscriptions", + "entitlements", + "serverNotifications", + "expiration", + ]); + const capabilities = { + specVersion: HTTP_BINDING.protocolVersion, + implementation: { + name: "Memory provider — separately implemented fixture", + }, + eventTypes: [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked", + ], + stores: { + [fixture.store]: Object.fromEntries( + Object.keys( + providerCapabilitiesSchema.$defs.StoreCapabilities.properties, + ).map((name) => [ + name, + { + provider: supported.has(name), + implementation: supported.has(name), + notes: + "Fictional store only; no complete profile or production claim.", + }, + ]), + ), + }, + }; + + function snapshot(purchase) { + return { + store: fixture.store, + productId: fixture.productId, + state: purchase.expired ? "Expired" : "Active", + active: !purchase.expired && now() < fixture.expiresAt, + expiresAt: fixture.expiresAt, + willRenew: purchase.renews, + }; + } + + function emit(eventType, purchase) { + const event = { + eventId: randomUUID(), + eventType, + eventVersion: COMMERCE_EVENT_VERSION, + occurredAt: now(), + processedAt: now(), + store: fixture.store, + environment: "local-fixture", + projectId: "memory_example", + userId: purchase.owner, + productId: fixture.productId, + subscription: snapshot(purchase), + }; + if (!validate("#/$defs/CommerceEvent", event)) + throw new Error("Invalid event"); + outbox.push({ + event, + attempts: 0, + status: "pending", + nextAt: now(), + deliveryId: randomUUID(), + }); + } + + const handlers = { + providerCapabilities: () => capabilities, + verifyPurchase(input) { + if (input.store !== fixture.store) return { error: "UNSUPPORTED_STORE" }; + if (typeof input.evidence !== "string") + return { error: "INVALID_REQUEST" }; + if (input.evidence === "local-upstream-outage") + return { error: "VERIFICATION_FAILED" }; + const accepted = input.evidence === fixture.evidence; + if (accepted && !purchases.has(input.evidence)) + purchases.set(input.evidence, { + owner: null, + renews: true, + expired: false, + granted: false, + }); + return { + store: fixture.store, + isValid: accepted && now() < fixture.expiresAt, + state: !accepted + ? "INAUTHENTIC" + : now() >= fixture.expiresAt + ? "EXPIRED" + : "ENTITLED", + ...(accepted ? { productId: fixture.productId } : {}), + environment: "local-fixture", + }; + }, + bindPurchase(input) { + if (input.store !== fixture.store) return { error: "UNSUPPORTED_STORE" }; + if (typeof input.evidence !== "string") + return { error: "INVALID_REQUEST" }; + const purchase = purchases.get(input.evidence); + if (!purchase) return { bound: false }; + if (purchase.owner === null) { + purchase.owner = input.userId; + if (snapshot(purchase).active) { + emit("entitlement.granted", purchase); + purchase.granted = true; + } + } + return { bound: purchase.owner === input.userId }; + }, + entitlements({ userId }) { + const subscriptions = [...purchases.values()] + .filter((purchase) => purchase.owner === userId) + .map(snapshot) + .filter((subscription) => subscription.active); + return { + userId, + productIds: [...new Set(subscriptions.map((row) => row.productId))], + subscriptions, + }; + }, + subscriptionStatus({ userId }) { + const purchase = [...purchases.values()].find( + (row) => row.owner === userId, + ); + const subscription = purchase ? snapshot(purchase) : undefined; + return { + active: subscription?.active ?? false, + ...(subscription ? { subscription } : {}), + }; + }, + }; + + async function fetch(request) { + const url = new URL(request.url); + const spec = HTTP_BINDING.operations.find( + (entry) => entry.path === url.pathname && entry.method === request.method, + ); + if (!spec) return protocolError("NOT_FOUND"); + if ( + spec.auth !== "none" && + request.headers.get("authorization") !== credential + ) + return protocolError("UNAUTHORIZED"); + if (!handlers[spec.name]) return protocolError("UNSUPPORTED_PROFILE"); + let input; + try { + input = spec.input + ? request.method === "GET" + ? Object.fromEntries(url.searchParams) + : await request.json() + : null; + } catch { + return protocolError("INVALID_REQUEST"); + } + if (spec.input && !validate(spec.input, input)) + return protocolError("INVALID_REQUEST"); + const result = handlers[spec.name](input); + if (result.error) return protocolError(result.error); + if (!validate(operation(spec.name).result, result)) + return protocolError("INTERNAL_ERROR"); + return Response.json(result, { status: spec.successStatus }); + } + + function observe(kind) { + const purchase = purchases.get(fixture.evidence); + if (!purchase?.owner) throw new Error("Bind the fixture purchase first"); + if (kind === "cancel") { + if (!purchase.renews) return; + purchase.renews = false; + emit("subscription.canceled", purchase); + } else if (kind === "expire" && now() >= fixture.expiresAt) { + if (purchase.expired) return; + purchase.expired = true; + purchase.renews = false; + emit("subscription.expired", purchase); + if (purchase.granted) { + emit("entitlement.revoked", purchase); + purchase.granted = false; + } + } else throw new Error("Invalid fixture transition"); + } + + function signed(event, secret) { + const body = JSON.stringify(event); + const timestamp = String(Math.floor(now() / 1000)); + const digest = createHmac("sha256", secret) + .update(Buffer.concat([Buffer.from(`${timestamp}.`), Buffer.from(body)])) + .digest("hex"); + return { + body, + headers: { + "content-type": WEBHOOK.contentType, + [WEBHOOK.timestampHeader]: timestamp, + [WEBHOOK.signatureHeader]: WEBHOOK.signaturePrefix + digest, + [WEBHOOK.eventIdHeader]: event.eventId, + }, + }; + } + + async function flush(url, secret) { + const results = []; + for (const item of outbox.filter( + (row) => row.status === "pending" && row.nextAt <= now(), + )) { + const request = signed(item.event, secret); + let status; + try { + status = ( + await globalThis.fetch(url, { + method: "POST", + ...request, + headers: { + ...request.headers, + [WEBHOOK.deliveryIdHeader]: item.deliveryId, + }, + redirect: "error", + signal: AbortSignal.timeout(5000), + }) + ).status; + } catch { + status = 503; + } + item.attempts += 1; + const retryable = status === 408 || status === 429 || status >= 500; + item.status = + status >= 200 && status < 300 + ? "delivered" + : retryable && item.attempts < 3 + ? "pending" + : "dead-letter"; + item.nextAt = now() + 30000 * 2 ** (item.attempts - 1); + results.push({ + httpStatus: status, + status: item.status, + attempt: item.attempts, + }); + } + return results; + } + + return { + fetch, + observe, + flush, + signed, + events: () => outbox.map((row) => row.event), + }; +} diff --git a/composition/purchase-flow.mjs b/composition/purchase-flow.mjs new file mode 100644 index 0000000..2bf375d --- /dev/null +++ b/composition/purchase-flow.mjs @@ -0,0 +1,25 @@ +// Host callbacks are integration-specific; this is not a protocol paywall API. +export function createPurchaseFlow({ purchase, fulfill, finish }) { + let busy = false; + return async function select(productId) { + if (busy) return { status: "busy" }; + busy = true; + try { + const result = await purchase(productId); + if (result.status === "pending" || result.status === "canceled") + return { status: result.status }; + if (result.status !== "purchased") throw new Error("Purchase failed"); + const access = await fulfill(result.evidence, productId); + try { + await finish(result); + } catch { + return { status: "finish-pending", access }; + } + return { status: "fulfilled", access }; + } catch { + return { status: "failed" }; + } finally { + busy = false; + } + }; +} diff --git a/composition/receiver.test.mjs b/composition/receiver.test.mjs new file mode 100644 index 0000000..165e83e --- /dev/null +++ b/composition/receiver.test.mjs @@ -0,0 +1,103 @@ +import { test, expect } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { WEBHOOK, COMMERCE_EVENT_VERSION } from "openiap-commerce-protocol"; +import { createReceiver, sign } from "../webhooks.mjs"; +import { runComposition } from "./run.mjs"; + +test("the original SQLite and memory composition remains compatible with the receiver", async () => { + const report = await runComposition(); + expect(report.checks.length).toBeGreaterThan(0); +}); + +test("one inbox scopes event IDs to authenticated emitters, survives upgrade, and discards erased users", async () => { + const directory = mkdtempSync(join(tmpdir(), "commerce-receiver-")); + const path = join(directory, "inbox.sqlite"); + const now = Date.now(); + const emitters = [ + { name: "example", projectId: "example_project", secret: "example-key" }, + { name: "iapkit", projectId: "kit_project", secret: "kit-key" }, + ]; + const event = { + eventId: "same-event-id", + eventType: "subscription.canceled", + eventVersion: COMMERCE_EVENT_VERSION, + occurredAt: now, + processedAt: now, + store: "fixture", + environment: "local-fixture", + projectId: emitters[0].projectId, + userId: "alice", + productId: "premium.monthly", + subscription: { + productId: "premium.monthly", + state: "Active", + active: true, + expiresAt: now + 60000, + willRenew: false, + }, + }; + const old = new Database(path, { create: true }); + old.exec( + "CREATE TABLE inbox (event_id TEXT PRIMARY KEY, body TEXT NOT NULL)", + ); + old + .query("INSERT INTO inbox VALUES (?, ?)") + .run(event.eventId, JSON.stringify(event)); + old.close(); + let receiver = createReceiver(path, emitters, () => now); + const post = (emitter, value) => { + const body = JSON.stringify(value), + timestamp = String(Math.floor(now / 1000)); + return receiver.fetch( + new Request("http://localhost/webhooks/commerce", { + method: "POST", + body, + headers: { + [WEBHOOK.timestampHeader]: timestamp, + [WEBHOOK.signatureHeader]: sign(emitter.secret, timestamp, body), + [WEBHOOK.eventIdHeader]: value.eventId, + }, + }), + ); + }; + try { + expect(await (await post(emitters[0], event)).json()).toEqual({ + accepted: true, + duplicate: true, + }); + const kitEvent = { ...event, projectId: emitters[1].projectId }; + expect((await post(emitters[0], kitEvent)).status).toBe(401); + expect(receiver.count()).toBe(1); + expect(await (await post(emitters[1], kitEvent)).json()).toEqual({ + accepted: true, + duplicate: false, + }); + expect(receiver.count()).toBe(2); + receiver.close(); + receiver = createReceiver(path, emitters, () => now); + expect(await (await post(emitters[1], kitEvent)).json()).toEqual({ + accepted: true, + duplicate: true, + }); + expect(receiver.eraseUser("alice")).toBe(2); + receiver.close(); + receiver = createReceiver(path, emitters, () => now); + for (const emitter of emitters) + expect( + await ( + await post(emitter, { + ...event, + projectId: emitter.projectId, + eventId: "late-event", + }) + ).json(), + ).toEqual({ accepted: true, discarded: "erased-user" }); + expect(receiver.count()).toBe(0); + } finally { + receiver.close(); + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/composition/run.mjs b/composition/run.mjs new file mode 100644 index 0000000..2f88cb2 --- /dev/null +++ b/composition/run.mjs @@ -0,0 +1,583 @@ +import assert from "node:assert/strict"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { WEBHOOK, HTTP_BINDING } from "openiap-commerce-protocol"; +import { createProvider, FIXTURE, CREDENTIALS } from "../provider.mjs"; +import { createReceiver, deliver, sign } from "../webhooks.mjs"; +import { createCommerceClient } from "./commerce-client.mjs"; +import { createMemoryProvider } from "./memory-provider.mjs"; +import { createPurchaseFlow } from "./purchase-flow.mjs"; + +export const COMPOSITION_SOURCES = [ + "composition/commerce-client.mjs", + "composition/memory-provider.mjs", + "composition/purchase-flow.mjs", + "composition/run.mjs", + "composition/README.md", + "contract.mjs", + "provider.mjs", + "webhooks.mjs", + "package.json", + "package-lock.json", +]; +const root = new URL("../", import.meta.url); +export const sourceHashes = () => + Object.fromEntries( + COMPOSITION_SOURCES.map((name) => [ + name, + createHash("sha256") + .update(readFileSync(new URL(name, root))) + .digest("hex"), + ]), + ); + +export async function runComposition() { + const initialHashes = sourceHashes(); + const directory = mkdtempSync(join(tmpdir(), "commerce-composition-")); + const servers = [], + receivers = []; + const checks = [], + results = [], + traces = []; + const check = (name, actual, expected) => { + assert.deepEqual(actual, expected, name); + checks.push(name); + }; + let time = FIXTURE.startsAt; + const now = () => time; + const sqlite = createProvider(join(directory, "provider.sqlite"), now); + const memoryCredential = `Bearer fixture-${randomBytes(16).toString("hex")}`; + const memory = createMemoryProvider({ + fixture: FIXTURE, + credential: memoryCredential, + now, + }); + const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence }; + const serve = (fetch) => { + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + maxRequestBodySize: 65536, + fetch, + }); + servers.push(server); + return `http://127.0.0.1:${server.port}`; + }; + const providers = [ + { + id: "sqlite", + label: "SQLite provider", + source: "provider.mjs", + fetch: sqlite.fetch, + credential: CREDENTIALS.server, + observe: (kind) => + sqlite.observe({ id: randomUUID(), kind, occurredAt: now() }), + events: () => + sqlite.db + .query("SELECT body FROM outbox ORDER BY rowid") + .all() + .map((row) => JSON.parse(row.body)), + signed: (event, secret) => { + const body = JSON.stringify(event), + timestamp = String(Math.floor(now() / 1000)); + return { + body, + headers: { + "content-type": WEBHOOK.contentType, + [WEBHOOK.timestampHeader]: timestamp, + [WEBHOOK.signatureHeader]: sign(secret, timestamp, body), + [WEBHOOK.eventIdHeader]: event.eventId, + }, + }; + }, + flush: (url, secret) => + deliver(sqlite, secret, now, (init) => + fetch(url, { + ...init, + redirect: "error", + signal: AbortSignal.timeout(5000), + }), + ), + }, + { + id: "memory", + label: "Memory provider", + source: "composition/memory-provider.mjs", + fetch: memory.fetch, + credential: memoryCredential, + observe: memory.observe, + events: memory.events, + signed: memory.signed, + flush: memory.flush, + }, + ]; + try { + for (const provider of providers) { + provider.baseUrl = serve(async (request) => { + const response = await provider.fetch(request); + traces.push({ + provider: provider.id, + method: request.method, + path: new URL(request.url).pathname, + status: response.status, + }); + return response; + }); + provider.client = createCommerceClient(provider); + provider.secret = randomBytes(32).toString("hex"); + provider.inboxPath = join(directory, `${provider.id}-inbox.sqlite`); + provider.receiver = createReceiver( + provider.inboxPath, + provider.secret, + now, + ); + receivers.push(provider.receiver); + provider.failures = 1; + provider.receiverUrl = serve((request) => { + if (provider.failures-- > 0) + return new Response("Try again", { status: 503 }); + return provider.receiver.fetch(request); + }); + const descriptor = await provider.client.call("providerCapabilities"); + check( + `${provider.id}: contract major matches`, + descriptor.specVersion.split(".")[0], + HTTP_BINDING.protocolVersion.split(".")[0], + ); + check( + `${provider.id}: no unearned profile claim`, + descriptor.profiles, + undefined, + ); + const before = await provider.client.call("entitlements", { + userId: FIXTURE.userId, + }); + check( + `${provider.id}: no access before verification`, + before.productIds, + [], + ); + check( + `${provider.id}: rejected evidence stays a verdict`, + ( + await provider.client.call("verifyPurchase", { + ...evidence, + evidence: "not-a-purchase", + }) + ).isValid, + false, + ); + await assert.rejects( + () => + provider.client.call("verifyPurchase", { + ...evidence, + evidence: "local-upstream-outage", + }), + /operation failed/, + ); + checks.push(`${provider.id}: verifier outage stays an operation failure`); + await assert.rejects( + () => + provider.client.fulfill( + { ...evidence, evidence: "not-a-purchase" }, + { userId: FIXTURE.userId, productId: FIXTURE.productId }, + ), + /not accepted/, + ); + checks.push(`${provider.id}: rejected verification cannot fulfill`); + check( + `${provider.id}: accepts fixture evidence`, + (await provider.client.call("verifyPurchase", evidence)).isValid, + true, + ); + check( + `${provider.id}: verification alone grants no access`, + (await provider.client.call("entitlements", { userId: FIXTURE.userId })) + .productIds, + [], + ); + + const order = []; + const flow = createPurchaseFlow({ + purchase: async (productId) => { + check( + `${provider.id}: selected product reaches purchase callback`, + productId, + FIXTURE.productId, + ); + order.push("purchase"); + return { + status: "purchased", + evidence: { ...evidence, userId: "untrusted_bob" }, + }; + }, + fulfill: async (input, productId) => { + order.push("fulfill"); + return provider.client.fulfill(input, { + userId: FIXTURE.userId, + productId, + }); + }, + finish: async () => { + order.push("finish"); + }, + }); + check( + `${provider.id}: paywall selection completes`, + (await flow(FIXTURE.productId)).status, + "fulfilled", + ); + check(`${provider.id}: fulfillment precedes finishing`, order, [ + "purchase", + "fulfill", + "finish", + ]); + const bound = await provider.client.call("entitlements", { + userId: FIXTURE.userId, + }); + check(`${provider.id}: trusted user receives Premium`, bound.productIds, [ + FIXTURE.productId, + ]); + check( + `${provider.id}: client identity is ignored`, + ( + await provider.client.call("entitlements", { + userId: "untrusted_bob", + }) + ).productIds, + [], + ); + check( + `${provider.id}: another user cannot take ownership`, + ( + await provider.client.call("bindPurchase", { + ...evidence, + userId: "other_user", + }) + ).bound, + false, + ); + const again = await provider.client.fulfill(evidence, { + userId: FIXTURE.userId, + productId: FIXTURE.productId, + }); + check( + `${provider.id}: repeated fulfillment returns the same access`, + again, + bound, + ); + provider.observe("cancel"); + const canceled = await provider.client.call("subscriptionStatus", { + userId: FIXTURE.userId, + }); + check( + `${provider.id}: cancellation preserves paid time`, + [canceled.active, canceled.subscription.willRenew], + [true, false], + ); + const firstDelivery = await provider.flush( + provider.receiverUrl, + provider.secret, + ); + check( + `${provider.id}: temporary receiver failure is queued for retry`, + firstDelivery[0].status, + "pending", + ); + provider.result = { + id: provider.id, + label: provider.label, + source: provider.source, + before, + bound, + canceled, + }; + } + + time += 30000; + for (const provider of providers) { + const retries = await provider.flush( + provider.receiverUrl, + provider.secret, + ); + check( + `${provider.id}: retry succeeds over HTTP`, + retries.map((row) => [row.httpStatus, row.attempt]), + [[200, 2]], + ); + check( + `${provider.id}: one inbox effect per event`, + provider.receiver.count(), + 2, + ); + const event = provider.events()[0]; + const request = provider.signed(event, provider.secret); + const repeat = await fetch(provider.receiverUrl, { + method: "POST", + ...request, + }); + check( + `${provider.id}: redelivery is a duplicate`, + (await repeat.json()).duplicate, + true, + ); + check( + `${provider.id}: modified body fails authentication`, + ( + await fetch(provider.receiverUrl, { + method: "POST", + ...request, + body: request.body + " ", + }) + ).status, + 401, + ); + const wrongKey = provider.signed( + event, + providers.find((other) => other !== provider).secret, + ); + check( + `${provider.id}: another emitter's key is rejected`, + (await fetch(provider.receiverUrl, { method: "POST", ...wrongKey })) + .status, + 401, + ); + + const extension = { + ...event, + eventId: "same-id-in-each-emitter", + extensions: { "partner.segment": "demo" }, + }; + const extended = provider.signed(extension, provider.secret); + check( + `${provider.id}: optional extension is accepted`, + (await fetch(provider.receiverUrl, { method: "POST", ...extended })) + .status, + 200, + ); + check( + `${provider.id}: equal IDs in different emitter inboxes are not lost`, + provider.receiver.count(), + 3, + ); + const invalidExtension = provider.signed( + { + ...event, + eventId: "invalid-extension", + extensions: { partner: { segment: "demo" } }, + }, + provider.secret, + ); + check( + `${provider.id}: an extension violating the contract is rejected`, + ( + await fetch(provider.receiverUrl, { + method: "POST", + ...invalidExtension, + }) + ).status, + 400, + ); + const stored = provider.receiver + .inspect() + .find( + (event) => + event.eventId === extension.eventId && + event.projectId === extension.projectId, + ); + check( + `${provider.id}: extension bytes survive storage`, + stored?.extensions, + extension.extensions, + ); + } + + time = FIXTURE.expiresAt; + for (const provider of providers) { + check( + `${provider.id}: read closes access at the deadline before a notification`, + (await provider.client.call("entitlements", { userId: FIXTURE.userId })) + .productIds, + [], + ); + provider.observe("expire"); + provider.result.expired = await provider.client.call( + "subscriptionStatus", + { userId: FIXTURE.userId }, + ); + check( + `${provider.id}: expired status is inactive`, + provider.result.expired.active, + false, + ); + await provider.flush(provider.receiverUrl, provider.secret); + check( + `${provider.id}: lifecycle and grant events match`, + provider.events().map((event) => event.eventType), + [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked", + ], + ); + check( + `${provider.id}: all emitted events were persisted`, + provider.receiver.count(), + 5, + ); + const wrong = createCommerceClient({ + ...provider, + credential: "Bearer invalid-fixture-key", + }); + await assert.rejects( + () => wrong.call("entitlements", { userId: FIXTURE.userId }), + /operation failed/, + ); + checks.push(`${provider.id}: wrong caller credentials fail closed`); + results.push(provider.result); + } + for (const state of ["before", "bound", "canceled", "expired"]) + check( + `same consumer observes equal ${state} results across providers`, + results[0][state], + results[1][state], + ); + + for (const status of ["pending", "canceled", "failed"]) { + let effects = 0; + const flow = createPurchaseFlow({ + purchase: async () => ({ status }), + fulfill: async () => { + effects++; + }, + finish: async () => { + effects++; + }, + }); + check( + `host: ${status} is shown without fulfillment or finishing`, + [(await flow(FIXTURE.productId)).status, effects], + [status, 0], + ); + } + let releasePurchase; + const blocked = new Promise((resolve) => { + releasePurchase = resolve; + }); + const busyFlow = createPurchaseFlow({ + purchase: async () => { + await blocked; + return { status: "canceled" }; + }, + fulfill: async () => { + throw new Error("Unexpected fulfillment"); + }, + finish: async () => { + throw new Error("Unexpected finish"); + }, + }); + const inProgress = busyFlow(FIXTURE.productId); + check( + "host: repeated selection does not start another purchase", + (await busyFlow(FIXTURE.productId)).status, + "busy", + ); + releasePurchase(); + await inProgress; + check( + "host: selection becomes available after cancellation", + (await busyFlow(FIXTURE.productId)).status, + "canceled", + ); + let finished = false; + const failedBackend = createPurchaseFlow({ + purchase: async () => ({ status: "purchased", evidence }), + fulfill: async () => { + throw new Error("Backend unavailable"); + }, + finish: async () => { + finished = true; + }, + }); + check( + "host: backend failure does not finish a purchase", + [(await failedBackend(FIXTURE.productId)).status, finished], + ["failed", false], + ); + const failedFinish = createPurchaseFlow({ + purchase: async () => ({ status: "purchased", evidence }), + fulfill: async () => ({ productIds: [FIXTURE.productId] }), + finish: async () => { + throw new Error("Finish unavailable"); + }, + }); + check( + "host: a finish failure preserves confirmed access", + await failedFinish(FIXTURE.productId), + { status: "finish-pending", access: { productIds: [FIXTURE.productId] } }, + ); + const malformedUrl = serve(() => + Response.json({ + userId: FIXTURE.userId, + productIds: [FIXTURE.productId], + }), + ); + await assert.rejects( + () => + createCommerceClient({ + baseUrl: malformedUrl, + credential: "fixture", + }).call("entitlements", { userId: FIXTURE.userId }), + /Invalid operation result/, + ); + checks.push("client: a malformed success response is rejected over HTTP"); + check( + "consumer and provider source files stay unchanged throughout the run", + sourceHashes(), + initialHashes, + ); + return { + recordedAt: new Date().toISOString(), + scope: + "Two separately implemented fixture providers and two scoped receivers over loopback HTTP in one Bun process. One backend client and receiver implementation, unchanged across both configurations. Host purchase callbacks are simulated.", + limits: [ + "Same project authorship; no independent organization validation.", + "No store purchase, SDK/device checkout, complete profile conformance, or production deployment.", + "SQLite versus in-memory state is tested on fresh stores, not a historical-data migration.", + "One trusted emitter/project and secret per receiver database; no cross-provider deduplication claim.", + ], + protocolVersion: HTTP_BINDING.protocolVersion, + sourceHashes: initialHashes, + configurationChanges: [ + "Provider URL", + "Server credential", + "Emitter endpoint, signing secret, and isolated inbox database", + ], + checks, + results, + traces, + }; + } finally { + await Promise.all(servers.map((server) => server.stop(true))); + for (const receiver of receivers) receiver.close(); + sqlite.close(); + rmSync(directory, { recursive: true, force: true }); + } +} + +if (import.meta.main) { + const report = await runComposition(); + if (process.argv[2] === "--record") { + assert(process.argv[3], "Provide the report filename"); + writeFileSync( + resolve(process.argv[3]), + JSON.stringify(report, null, 2) + "\n", + ); + } + console.log( + `Composition: ${report.checks.length} checks passed; same client and receiver source across two fixture providers. No store or production service contacted.`, + ); +} diff --git a/consumer.mjs b/consumer.mjs index 972ef32..1e240f3 100644 --- a/consumer.mjs +++ b/consumer.mjs @@ -7,7 +7,7 @@ import { WEBHOOK, COMMERCE_EVENT_VERSION } from "openiap-commerce-protocol"; import { createReceiver, sign } from "./webhooks.mjs"; import { validate } from "./contract.mjs"; -// One configured emitter/project and signing key per receiver database. +// One app inbox; named emitters can bind separate signing keys to project IDs. export function startConsumer({ secret, path, port = 0, now = Date.now }) { assert(secret, "Set COMMERCE_WEBHOOK_SECRET to the provider signing secret."); let receiver = createReceiver(path, secret, now); @@ -27,6 +27,8 @@ export function startConsumer({ secret, path, port = 0, now = Date.now }) { return { url: `http://127.0.0.1:${server.port}/webhooks/commerce`, count: () => receiver.count(), + eraseUser: (userId) => receiver.eraseUser(userId), + inspect: () => receiver.inspect(), reopen() { receiver.close(); receiver = createReceiver(path, secret, now); diff --git a/dashboard.html b/dashboard.html index b283719..4504686 100644 --- a/dashboard.html +++ b/dashboard.html @@ -91,7 +91,7 @@ summary { cursor: pointer; padding: 8px 0; font-weight: 600; } ol { display: grid; - grid-template-columns: repeat(6, 1fr); + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; padding: 0; margin: 0 0 24px; @@ -227,7 +227,7 @@ Ready to run

From a purchase to current access.

- Run the six milestones against a new, empty database. + Follow a purchase from verification to account deletion in a new, empty database.

@@ -302,7 +302,7 @@

Actual request results

for (const row of rows) { const tr = body.insertRow(); for (const [, key] of columns) - tr.insertCell().textContent = String(row[key] ?? "Unbound"); + tr.insertCell().textContent = String(row[key] ?? (key === "userId" ? "No user identity" : "—")); } $(target).append(table); } diff --git a/docs/build/07-account-erasure-interoperable-2/attempt-1.txt b/docs/build/07-account-erasure-interoperable-2/attempt-1.txt new file mode 100644 index 0000000..ca321e5 --- /dev/null +++ b/docs/build/07-account-erasure-interoperable-2/attempt-1.txt @@ -0,0 +1,20 @@ +{ + "startedAt": "2026-09-08T19:45:56.568Z", + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 360ms\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && bun test composition/app-backend.test.mjs composition/receiver.test.mjs\n\nCommerce Protocol Example: 159 checks passed. No store or production service contacted.\nbun test v1.3.13 (bf2e2cec)\n\ncomposition/app-backend.test.mjs:\n(pass) account deletion waits for in-flight fulfillment and retries provider erasure after app restart [80.90ms]\n\ncomposition/receiver.test.mjs:\n(pass) the original SQLite and memory composition remains compatible with the receiver [61.77ms]\n(pass) one inbox scopes event IDs to authenticated emitters, survives upgrade, and discards erased users [5.94ms]\n\n 3 pass\n 0 fail\n 20 expect() calls\nRan 3 tests across 2 files. [222.00ms]\n" + }, + { + "command": "npm run test:tooling", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [14.42ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [68.94ms]\n(pass) failed attempts get new numbers and private paths are redacted [1.08ms]\n(pass) unfinished captures do not hide completed records [1.75ms]\n(pass) checkpoint replay follows predecessors instead of directory name order [2.51ms]\n\n 5 pass\n 0 fail\nRan 5 tests across 1 file. [105.00ms]\n" + } + ] +} diff --git a/docs/build/07-account-erasure-interoperable-2/changes.patch b/docs/build/07-account-erasure-interoperable-2/changes.patch new file mode 100644 index 0000000..db83352 --- /dev/null +++ b/docs/build/07-account-erasure-interoperable-2/changes.patch @@ -0,0 +1,150 @@ +diff --git a/checkpoint-tools.mjs b/checkpoint-tools.mjs +index 112a019..61adf66 100644 +--- a/checkpoint-tools.mjs ++++ b/checkpoint-tools.mjs +@@ -153,7 +153,7 @@ export function createPatch(before, after) { + } + } + export function readRecords(directory) { +- return readdirSync(directory) ++ const records = readdirSync(directory) + .filter( + (name) => + /^\d\d-[\w-]+$/.test(name) && +@@ -163,4 +163,25 @@ export function readRecords(directory) { + .map((name) => + JSON.parse(readFileSync(join(directory, name, "run.json"), "utf8")), + ); ++ const byId = new Map(records.map((record) => [record.id, record])); ++ const ordered = [], ++ visiting = new Set(), ++ visited = new Set(); ++ function visit(record) { ++ if (visited.has(record.id)) return; ++ assert(!visiting.has(record.id), `Checkpoint cycle: ${record.id}`); ++ visiting.add(record.id); ++ if (record.previous) { ++ assert( ++ byId.has(record.previous), ++ `Missing predecessor: ${record.previous}`, ++ ); ++ visit(byId.get(record.previous)); ++ } ++ visiting.delete(record.id); ++ visited.add(record.id); ++ ordered.push(record); ++ } ++ records.forEach(visit); ++ return ordered; + } +diff --git a/checkpoint-tools.test.mjs b/checkpoint-tools.test.mjs +index e807fae..a45e857 100644 +--- a/checkpoint-tools.test.mjs ++++ b/checkpoint-tools.test.mjs +@@ -108,3 +108,29 @@ test("unfinished captures do not hide completed records", () => { + rmSync(temp, { recursive: true, force: true }); + } + }); ++ ++test("checkpoint replay follows predecessors instead of directory name order", () => { ++ const temp = mkdtempSync(join(tmpdir(), "commerce-capture-test-")); ++ const records = [ ++ { id: "07-reviewed", previous: "01-start" }, ++ { id: "07-interoperable", previous: "07-reviewed" }, ++ { id: "01-start" }, ++ ]; ++ try { ++ for (const record of records) { ++ mkdirSync(join(temp, record.id)); ++ writeFileSync(join(temp, record.id, "run.json"), JSON.stringify(record)); ++ } ++ assert.deepEqual( ++ readRecords(temp).map((record) => record.id), ++ ["01-start", "07-reviewed", "07-interoperable"], ++ ); ++ writeFileSync( ++ join(temp, "01-start/run.json"), ++ JSON.stringify({ id: "01-start", previous: "07-interoperable" }), ++ ); ++ assert.throws(() => readRecords(temp), /Checkpoint cycle/); ++ } finally { ++ rmSync(temp, { recursive: true, force: true }); ++ } ++}); +diff --git a/checkpoint.json b/checkpoint.json +index 4860ddb..e139d74 100644 +--- a/checkpoint.json ++++ b/checkpoint.json +@@ -1,8 +1,8 @@ + { + "step": 7, +- "id": "07-account-erasure-interoperable", ++ "id": "07-account-erasure-interoperable-2", + "title": "Delete the account", + "built": "Account erasure with shared receiver and retry coordination", + "result": "The account identity is removed; retries and restart cannot restore it.", +- "previous": "07-account-erasure-reviewed" ++ "previous": "07-account-erasure-interoperable" + } +diff --git a/composition/receiver.test.mjs b/composition/receiver.test.mjs +index 24e83b4..165e83e 100644 +--- a/composition/receiver.test.mjs ++++ b/composition/receiver.test.mjs +@@ -5,6 +5,12 @@ import { tmpdir } from "node:os"; + import { join } from "node:path"; + import { WEBHOOK, COMMERCE_EVENT_VERSION } from "openiap-commerce-protocol"; + import { createReceiver, sign } from "../webhooks.mjs"; ++import { runComposition } from "./run.mjs"; ++ ++test("the original SQLite and memory composition remains compatible with the receiver", async () => { ++ const report = await runComposition(); ++ expect(report.checks.length).toBeGreaterThan(0); ++}); + + test("one inbox scopes event IDs to authenticated emitters, survives upgrade, and discards erased users", async () => { + const directory = mkdtempSync(join(tmpdir(), "commerce-receiver-")); +diff --git a/composition/run.mjs b/composition/run.mjs +index 7fceee5..2f88cb2 100644 +--- a/composition/run.mjs ++++ b/composition/run.mjs +@@ -3,7 +3,6 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; + import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; + import { tmpdir } from "node:os"; + import { join, resolve } from "node:path"; +-import { Database } from "bun:sqlite"; + import { WEBHOOK, HTTP_BINDING } from "openiap-commerce-protocol"; + import { createProvider, FIXTURE, CREDENTIALS } from "../provider.mjs"; + import { createReceiver, deliver, sign } from "../webhooks.mjs"; +@@ -381,21 +380,18 @@ export async function runComposition() { + ).status, + 400, + ); +- const inspector = new Database(provider.inboxPath, { readonly: true }); +- try { +- const stored = JSON.parse( +- inspector +- .query("SELECT body FROM inbox WHERE event_id = ?") +- .get(extension.eventId).body, ++ const stored = provider.receiver ++ .inspect() ++ .find( ++ (event) => ++ event.eventId === extension.eventId && ++ event.projectId === extension.projectId, + ); +- check( +- `${provider.id}: extension bytes survive storage`, +- stored.extensions, +- extension.extensions, +- ); +- } finally { +- inspector.close(); +- } ++ check( ++ `${provider.id}: extension bytes survive storage`, ++ stored?.extensions, ++ extension.extensions, ++ ); + } + + time = FIXTURE.expiresAt; diff --git a/docs/build/07-account-erasure-interoperable-2/mobile.png b/docs/build/07-account-erasure-interoperable-2/mobile.png new file mode 100644 index 0000000..c5c65fa Binary files /dev/null and b/docs/build/07-account-erasure-interoperable-2/mobile.png differ diff --git a/docs/build/07-account-erasure-interoperable-2/run.json b/docs/build/07-account-erasure-interoperable-2/run.json new file mode 100644 index 0000000..5d2f01a --- /dev/null +++ b/docs/build/07-account-erasure-interoperable-2/run.json @@ -0,0 +1,911 @@ +{ + "step": 7, + "id": "07-account-erasure-interoperable-2", + "title": "Delete the account", + "built": "Account erasure with shared receiver and retry coordination", + "result": "The account identity is removed; retries and restart cannot restore it.", + "previous": "07-account-erasure-interoperable", + "startedAt": "2026-09-08T19:45:56.568Z", + "recordedAt": "2026-09-08T19:46:01.235Z", + "packageVersion": "0.1.0", + "task": "# Review the completed backend\n\nApply the Fable 5.1 max CLI review to the final checkpoint. Add the missing\nfirst-binding grant event in the same transaction as ownership, reject a\npremature expiry without consuming its observation, and verify both rollback\nand expiry boundaries. Correct test labels to describe what they exercise.\n\nRepair patch generation without rewriting historical source or screenshots.\nBuild patches from the preceding archived source and verify their hashes.\nInstall and test each archive outside the monorepo with npm. Capture the revised\nfinal screen on desktop and mobile, then export only matching source evidence.\n\nKeep the original six checkpoints as history. Explain their incomplete discovery\nand missing grant behavior; do not describe them as conformant providers. Keep\nall changes uncommitted for maintainer review.\n\nApply the second review: retain gate delivery state for delayed expiry,\nretain store occurrence on delayed binding, preserve consecutive failure logs,\nand state actual package contents and runtime requirements. Add a ready-to-run\ngeneric event receiver for an existing backend, reusing the same receiver\nhandler. Demonstrate signed lifecycle ingestion, duplicate delivery, tampering,\nand persisted inbox recovery over real local HTTP. Keep all samples fictional.\n\nAdd composable integration roles: experience, commerce, and data. Ship a short\nAI integration brief and a backend helper that maps Apple/Google OpenIAP\npurchase fields into the installed verification schema. Test invalid inputs\nand exclude client-supplied identity. Keep paywall UI APIs product-specific,\ncurrent IAPKit-only client helpers explicit, and store/device proof separate\nfrom local fixtures. Reuse the existing consumer and contract validators.\n\nThe first bridge test failed because store evidence was nested under an extra\n`evidence` key. Keep the failure output, use the installed input schema's\ntop-level `apple`/`google` members, and rerun that boundary check.\n\nApply the third CLI review. Make the integration brief reachable from the docs\nsite with absolute setup, source, and build-brief links. Accept signed webhook\ndelivery behind a reverse proxy that preserves the public Host header. Keep\nunfinished captures from blocking completed history, retain equal-time fixture\ntransitions, record the observed duplicate response, and configure Yarn's\nnode-modules linker. Preserve the proxy failure and rerun the checks.\n\nPrepare the standalone example for its first public commit. Rewrite the README\naround clone, run, inspect, choose a role, and verify. Put receiver and capture\nsetup in repository documentation so the example works before the docs site is\ndeployed. Preserve every earlier archive. Record this documentation revision,\nverify all source archives again, and export a stable current-source download.\nAdd CI that tests runtime, tooling, archives and the documentation export. This\nrevision is a local publication review, not another completed external review.\n\nFix the first Linux CI failure without rewriting historical recordings. macOS\nAppleDouble metadata must not count as source. Exclude it on extraction, omit it\nfrom new archives, and add a portable extraction regression test. Capture the\ncorrected tooling, verify every source revision, and rerun GitHub CI.\n\nCorrect the final CLI review finding: exercise cancellation at the expiry\nobservation timestamp so the check reaches the expired-state guard. Preserve\nthe earlier capture, record this revision, and verify its archive and patch.\n\nApply the Codex review: authenticate webhook body bytes before UTF-8 decoding.\nReject altered UTF-8 and inserted BOM bytes with an unchanged signature. Reject\nauthentically signed malformed UTF-8 before storage, and accept correctly signed\nUnicode and BOM bodies. Keep the reproduced failure, preserve old checkpoints,\nthen capture and verify the corrected revision.\n", + "sourceHashes": { + ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", + ".yarnrc.yml": "473e6def86fc03638120e0c01d0c8bbab095677256460fa4ea763e5d4697f270", + "AGENTS.md": "38764ea47552533ab5db55f7ade8c0025e2d7af9163715ce89f2d719f5475712", + "BUILD.md": "abad05223f40512522487d83337755d5c3222fda4614e5c2ea92f45d13be977d", + "INTEGRATE.md": "532f25bc83d96350381c8c801caf5837777624095a3f519053133dffdbee1cd8", + "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", + "README.md": "f46856f23a859637f4488074744dbc4f16a7a42999e27491aa832bac2089cbec", + "ai-task.md": "d000a11054750accc6bab8ddbf6d2fc6b674b12c3821ccbfd45729469923b8fe", + "capture.mjs": "4940347c92713f47965400fa9dca0908372ec2b3627612fccf40eb6b636a85a2", + "checkpoint-tools.mjs": "81de52062f97e23c97a445603a7e274258b4481c5d536274140af2211b9021b7", + "checkpoint-tools.test.mjs": "1ea348330db8dab85694ac79cfec76e9d3af1729263a6a05cae61e10e41b2175", + "checkpoint.json": "333176dbefcc211112f14d4ecd39a1a31a572f5e2e332c729e9be23beec1fb31", + "client-bridge.mjs": "a0058579b02c2f3a770c7ff7b59dfe41ce50761361d9a0c7a2df96ab1b6b4acf", + "composition/README.md": "99e302c7365cf3969f61be2c466f31f5cf0928b2f3f0351e45907666f0459a44", + "composition/app-backend.mjs": "b770db0706cc8fef6cd3a3d730245b25f0cc5bc5253a1c053b0923da53d875af", + "composition/app-backend.test.mjs": "9fbfc7fdb393b95bdf803dfb77e44a5d4311e5812e33a987b371a6b4a32a3b41", + "composition/commerce-client.mjs": "f1f1a056bc76801df25111cb12fdcde501d507a5461ef8b15bfe2f57979cd03c", + "composition/export.mjs": "ccc84dee42d59b1d2b09f16aec2b77a774765dd6fab0f341b01f1043a2e88225", + "composition/memory-provider.mjs": "edf6a7567ead756dc4a5c15d5b5b14f4d4314db31e3aa3c01c8ec543754fcc31", + "composition/purchase-flow.mjs": "ce6f98426c36eab346e900ac06ed31f2d387e542c518a48c92a67f7bae147ec8", + "composition/receiver.test.mjs": "1559036d9f0804017eeff544ed0eef7d7328111515eaeb38f6787cba815a1151", + "composition/run.mjs": "0e7aa1c8c5cab79f06d9853fa5b91f0e15f0322b4574b5f1a97f1ea119fc9b14", + "consumer.mjs": "d8e168238270309157d52ca777fea30f1d37bae38b96e88148314c894bdb1b4c", + "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", + "dashboard.html": "50a19acc0810453fa2ef55f4e5e958b4ba428764fc5a5edc10e262f9c990e532", + "erasure.mjs": "019cb67fba68fe0dee0937db6bcbdec5036090a49672d7769de7ba91d52f7d24", + "export-docs.mjs": "47cf25c7ba1942fb833ac6b496c1e06b4ae06a61f6328330c6a6a4337c164f84", + "package-lock.json": "6f5333cc45203d6a27fec82c9370a8c25fef7faab591467dd2ff3c79acd6ae1c", + "package.json": "396fc93b7d50240739af2b817c172ffab8692245bd919de3b26ee713b56d455b", + "provider.mjs": "e5c498cf01beedab756058f62cbeb5a533477ebb9da1b6064d48f4003e524094", + "scenario.mjs": "b8ff9024afd67701b5f70916a61fa7d538eaec281aa4b5cc660e1c205932a32d", + "server.mjs": "b0f185a3954fbe1d7a4dd15f0dc8638afcf04e386ec730ae17a15860784d9e36", + "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", + "verify-erasure.mjs": "65feaff2e8eb0a3247ecdbc0f511c8eef5e07a4c2c63155b07409ba7cb19be88", + "verify.mjs": "942f1cd3f663dd84350a20fba459f707436a82c691469caf2c2f36155fbd5b72", + "webhooks.mjs": "4838bac7a9083440998409aa1ab543999495ea6a907036bc78ba2002feb588b2" + }, + "checks": [ + "Fixture request matches the installed schema", + "Capabilities use the published response schema", + "Purchase storage starts empty", + "No profile conformance is claimed", + "Fixture evidence is accepted", + "Verification does not grant access", + "Invalid evidence produces a negative verdict", + "An upstream outage is not a negative verdict", + "Verification credentials cannot bind a user", + "The server binds Alice", + "Repeating the same binding succeeds", + "Bob cannot take Alice's purchase", + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none", + "Cancellation keeps paid access", + "Cancellation stops renewal", + "Cancellation queues one event", + "A 503 leaves a durable retry", + "Retry survives provider restart", + "Retry keeps the delivery identity", + "Redelivery has one inbox row per event", + "Access closes at expiry before a notification arrives", + "Duplicate store notification emits no extra event", + "Reopening both databases preserves state", + "Receiver deduplication survives restart", + "All four events reach the receiver", + "The final status is inactive", + "Signature vector: single-key", + "Receiver accepts vector: single-key", + "Signature vector: retry-after-backoff", + "Receiver accepts vector: retry-after-backoff", + "Signature vector: raw-utf8-body", + "Receiver accepts vector: raw-utf8-body", + "Signature vector: during-rotation", + "Receiver accepts vector: during-rotation", + "Signature vector: minimal-event-omits-extensions", + "Receiver accepts vector: minimal-event-omits-extensions", + "Receiver rejects: tampered-body", + "Receiver rejects: wrong-secret", + "Receiver rejects: timestamp-outside-tolerance", + "Receiver rejects: timestamp-not-in-signed-material", + "Receiver rejects: retry-reuses-first-signature", + "Receiver rejects: garbage-appended-to-valid-signature", + "Active: before expiry", + "Active: at expiry", + "Active: no deadline", + "InGracePeriod: before expiry", + "InGracePeriod: at expiry", + "InGracePeriod: no deadline", + "InBillingRetry: before expiry", + "InBillingRetry: at expiry", + "InBillingRetry: no deadline", + "Paused: before expiry", + "Paused: at expiry", + "Paused: no deadline", + "Expired: before expiry", + "Expired: at expiry", + "Expired: no deadline", + "Revoked: before expiry", + "Revoked: at expiry", + "Revoked: no deadline", + "Refunded: before expiry", + "Refunded: at expiry", + "Refunded: no deadline", + "Unknown: before expiry", + "Unknown: at expiry", + "Unknown: no deadline", + "FutureState: before expiry", + "FutureState: at expiry", + "FutureState: no deadline", + "Missing credentials are refused", + "Verification role cannot enumerate users", + "Malformed input is refused", + "A real store is not falsely accepted", + "Erasure rejects verification credentials", + "Cancellation after expiry is ignored", + "Expired fixture evidence has an expired verdict", + "Tampered HTTP body has no inbox effect", + "Changed UTF-8 bytes cannot reuse a signature", + "Inserted UTF-8 BOM cannot reuse a signature", + "Authenticated malformed UTF-8 is rejected before storage", + "Authentic Unicode bytes are accepted and stored", + "Authentic BOM bytes are verified before decoding", + "Receiver still has exactly four events", + "Cross-origin demo mutations are refused", + "Overlapping HTTP ownership claims have one winner", + "A cancellation older than the active row is ignored", + "Ignoring an old cancellation preserves renewal", + "Conflicting expiry is not consumed", + "Outbox failure rolls back subscription state", + "Failed transaction leaves the observation retryable", + "Exhausted retries enter dead-letter", + "Grant failure rejects binding", + "Grant failure rolls back ownership", + "An expired purchase can be bound", + "Binding expired evidence emits no grant", + "Delayed binding retains the store occurrence", + "Delayed binding records its actual processing time", + "Late expiry revokes a persisted grant exactly once", + "Repeated expiry emits no second revocation", + "Equal-time expiry retains its lifecycle transition", + "Equal-time observations revoke once and preserve both transitions", + "Health accepts the proxy public Host header", + "subscription.started: authenticated and saved", + "subscription.started: redelivery deduplicated", + "subscription.started: tampering rejected", + "entitlement.granted: authenticated and saved", + "entitlement.granted: redelivery deduplicated", + "entitlement.granted: tampering rejected", + "subscription.renewed: authenticated and saved", + "subscription.renewed: redelivery deduplicated", + "subscription.renewed: tampering rejected", + "subscription.canceled: authenticated and saved", + "subscription.canceled: redelivery deduplicated", + "subscription.canceled: tampering rejected", + "subscription.expired: authenticated and saved", + "subscription.expired: redelivery deduplicated", + "subscription.expired: tampering rejected", + "entitlement.revoked: authenticated and saved", + "entitlement.revoked: redelivery deduplicated", + "entitlement.revoked: tampering rejected", + "subscription.refunded: authenticated and saved", + "subscription.refunded: redelivery deduplicated", + "subscription.refunded: tampering rejected", + "One inbox record per event", + "Inbox survives reopening SQLite", + "apple: maps evidence without forwarding client identity", + "apple: matches the installed verification input schema", + "google: maps evidence without forwarding client identity", + "google: matches the installed verification input schema", + "Rejects missing purchase", + "Rejects unknown store", + "Rejects Amazon needs its own adapter", + "Rejects Horizon needs its own adapter", + "Rejects missing evidence", + "Rejects blank evidence", + "Rejects non-string evidence", + "Rejects oversized evidence", + "An active purchase has a delivered event copy", + "Erasure during delivery completes", + "An in-flight event cannot resurrect receiver data", + "An in-flight acknowledgement cannot resurrect the outbox", + "Repeated erase survives restart", + "An old signed event remains discarded after restart", + "A new event ID cannot bypass erasure", + "Verification cannot bind an erased purchase", + "Stale binding cannot restore an erased account", + "Another account cannot claim erased evidence", + "Erased account is inactive before paid expiry", + "Late lifecycle processing carries no erased identity", + "Persisted protocol records contain no erased user ID", + "Unknown-user erasure is accepted", + "Verification credentials cannot erase users", + "Provider erasure completes", + "Erasure retry after restart returns the same job", + "Purchase has no account identity", + "App event copies are erased", + "Erased account has no access", + "A stale binding retry cannot restore identity" + ], + "history": [ + { + "step": 1, + "title": "Start with the contract", + "built": "HTTP routes + schema validation + SQLite", + "result": "A running server, an empty purchase table, and no access.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "providerCapabilities", + "httpStatus": 200, + "body": { + "specVersion": "1.0", + "implementation": { + "name": "Commerce Protocol Example — fictional fixture store" + }, + "eventTypes": [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked" + ], + "stores": { + "fixture": { + "initialValidation": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "serverNotifications": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "subscriptions": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "renewalEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "refundEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "expiration": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "reconciliation": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "entitlements": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "revenueAmount": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + } + } + } + } + }, + { + "operation": "providerCapabilities", + "httpStatus": 200, + "body": { + "specVersion": "1.0", + "implementation": { + "name": "Commerce Protocol Example — fictional fixture store" + }, + "eventTypes": [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked" + ], + "stores": { + "fixture": { + "initialValidation": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "serverNotifications": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "subscriptions": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "renewalEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "refundEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "expiration": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "reconciliation": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "entitlements": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "revenueAmount": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + } + } + } + } + } + ], + "checks": [ + "Fixture request matches the installed schema", + "Capabilities use the published response schema", + "Purchase storage starts empty", + "No profile conformance is claimed" + ], + "totalChecks": 4 + }, + { + "step": 2, + "title": "Verify a purchase", + "built": "Fixture store adapter + purchase persistence", + "result": "Valid evidence is saved. Alice still has no access.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [ + { + "userId": null, + "productId": "premium.monthly", + "state": "Active", + "willRenew": 1 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "verifyPurchase", + "httpStatus": 200, + "body": { + "store": "fixture", + "isValid": true, + "state": "ENTITLED", + "productId": "premium.monthly", + "environment": "local-fixture" + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "verifyPurchase", + "httpStatus": 200, + "body": { + "store": "fixture", + "isValid": false, + "state": "INAUTHENTIC", + "environment": "local-fixture" + } + }, + { + "operation": "verifyPurchase", + "httpStatus": 502, + "body": { + "error": { + "code": "VERIFICATION_FAILED", + "message": "verification failed" + } + } + } + ], + "checks": [ + "Fixture evidence is accepted", + "Verification does not grant access", + "Invalid evidence produces a negative verdict", + "An upstream outage is not a negative verdict" + ], + "totalChecks": 8 + }, + { + "step": 3, + "title": "Connect it to a user", + "built": "Server authorization + atomic binding + entitlement reads", + "result": "Alice gets Premium. Another user cannot take the purchase.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 1 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": true + } + ] + }, + "deliveries": [ + { + "eventId": "4726131a-0b8e-4d09-9be8-90386fc48b25", + "deliveryId": "6ba05f18-3bd3-451d-9201-c28c2f6e7216", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + } + ], + "inboxCount": 0, + "responses": [ + { + "operation": "bindPurchase", + "httpStatus": 403, + "body": { + "error": { + "code": "FORBIDDEN", + "message": "forbidden" + } + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": true + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": true + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": false + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": true + } + ] + } + } + ], + "checks": [ + "Verification credentials cannot bind a user", + "The server binds Alice", + "Repeating the same binding succeeds", + "Bob cannot take Alice's purchase", + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none" + ], + "totalChecks": 14 + }, + { + "step": 4, + "title": "Handle cancellation", + "built": "Lifecycle processing + transactional event outbox", + "result": "Renewal stops. Alice keeps the time she already paid for.", + "simulatedTime": "2026-09-08T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + ] + }, + "deliveries": [ + { + "eventId": "4726131a-0b8e-4d09-9be8-90386fc48b25", + "deliveryId": "6ba05f18-3bd3-451d-9201-c28c2f6e7216", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + }, + { + "eventId": "ec06dad9-9c6c-430b-a3f8-4bf6ada88a8a", + "deliveryId": "2df26314-1a06-413e-ba21-5e10bedf194e", + "attempts": 0, + "status": "pending", + "eventType": "subscription.canceled" + } + ], + "inboxCount": 0, + "responses": [ + { + "operation": "subscriptionStatus", + "httpStatus": 200, + "body": { + "active": true, + "subscription": { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + } + } + ], + "checks": [ + "Cancellation keeps paid access", + "Cancellation stops renewal", + "Cancellation queues one event" + ], + "totalChecks": 17 + }, + { + "step": 5, + "title": "Deliver, retry, deduplicate", + "built": "HMAC signatures + retry worker + durable receiver inbox", + "result": "A 503 retries successfully. Redelivery creates no second inbox row.", + "simulatedTime": "2026-09-08T09:00:31.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + ] + }, + "deliveries": [ + { + "eventId": "4726131a-0b8e-4d09-9be8-90386fc48b25", + "deliveryId": "6ba05f18-3bd3-451d-9201-c28c2f6e7216", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "ec06dad9-9c6c-430b-a3f8-4bf6ada88a8a", + "deliveryId": "2df26314-1a06-413e-ba21-5e10bedf194e", + "attempts": 3, + "status": "delivered", + "eventType": "subscription.canceled" + } + ], + "inboxCount": 2, + "responses": [ + { + "operation": "webhook: receiver unavailable", + "body": [ + { + "eventId": "4726131a-0b8e-4d09-9be8-90386fc48b25", + "deliveryId": "6ba05f18-3bd3-451d-9201-c28c2f6e7216", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + }, + { + "eventId": "ec06dad9-9c6c-430b-a3f8-4bf6ada88a8a", + "deliveryId": "2df26314-1a06-413e-ba21-5e10bedf194e", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + } + ] + }, + { + "operation": "webhook: retry after restart", + "body": [ + { + "eventId": "4726131a-0b8e-4d09-9be8-90386fc48b25", + "deliveryId": "6ba05f18-3bd3-451d-9201-c28c2f6e7216", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + }, + { + "eventId": "ec06dad9-9c6c-430b-a3f8-4bf6ada88a8a", + "deliveryId": "2df26314-1a06-413e-ba21-5e10bedf194e", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + } + ] + }, + { + "operation": "webhook: lost-ack redelivery", + "body": [ + { + "eventId": "4726131a-0b8e-4d09-9be8-90386fc48b25", + "deliveryId": "6ba05f18-3bd3-451d-9201-c28c2f6e7216", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + }, + { + "eventId": "ec06dad9-9c6c-430b-a3f8-4bf6ada88a8a", + "deliveryId": "2df26314-1a06-413e-ba21-5e10bedf194e", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + } + ] + } + ], + "checks": [ + "A 503 leaves a durable retry", + "Retry survives provider restart", + "Retry keeps the delivery identity", + "Redelivery has one inbox row per event" + ], + "totalChecks": 21 + }, + { + "step": 6, + "title": "Expire access and restart", + "built": "Expiry-aware reads + recovery from SQLite", + "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", + "simulatedTime": "2026-10-07T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Expired", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [ + { + "eventId": "4726131a-0b8e-4d09-9be8-90386fc48b25", + "deliveryId": "6ba05f18-3bd3-451d-9201-c28c2f6e7216", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "ec06dad9-9c6c-430b-a3f8-4bf6ada88a8a", + "deliveryId": "2df26314-1a06-413e-ba21-5e10bedf194e", + "attempts": 3, + "status": "delivered", + "eventType": "subscription.canceled" + }, + { + "eventId": "3a6f3811-0044-4350-bc51-30fe2d6f703e", + "deliveryId": "5f4c72aa-801a-4d91-8ddc-ab8c7b65b864", + "attempts": 1, + "status": "delivered", + "eventType": "subscription.expired" + }, + { + "eventId": "a327ee80-cdf2-4529-8606-7f8513a8dadb", + "deliveryId": "669fdad2-517d-4dae-81b7-76d856d3607a", + "attempts": 1, + "status": "delivered", + "eventType": "entitlement.revoked" + } + ], + "inboxCount": 4, + "responses": [ + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "webhook: expiry + revocation", + "body": [ + { + "eventId": "3a6f3811-0044-4350-bc51-30fe2d6f703e", + "deliveryId": "5f4c72aa-801a-4d91-8ddc-ab8c7b65b864", + "httpStatus": 200, + "attempt": 1, + "status": "delivered" + }, + { + "eventId": "a327ee80-cdf2-4529-8606-7f8513a8dadb", + "deliveryId": "669fdad2-517d-4dae-81b7-76d856d3607a", + "httpStatus": 200, + "attempt": 1, + "status": "delivered" + } + ] + }, + { + "operation": "subscriptionStatus", + "httpStatus": 200, + "body": { + "active": false, + "subscription": { + "productId": "premium.monthly", + "state": "Expired", + "active": false, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + } + } + ], + "checks": [ + "Access closes at expiry before a notification arrives", + "Duplicate store notification emits no extra event", + "Reopening both databases preserves state", + "Receiver deduplication survives restart", + "All four events reach the receiver", + "The final status is inactive" + ], + "totalChecks": 27 + }, + { + "step": 7, + "title": "Delete the account", + "built": "Idempotent erasure + receiver cleanup + durable deletion guard", + "result": "Alice is removed from purchases and event copies. Late deliveries cannot restore her account data.", + "simulatedTime": "2026-10-07T09:00:00.000Z", + "purchases": [ + { + "userId": null, + "productId": "premium.monthly", + "state": "Expired", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "eraseUser", + "httpStatus": 403, + "body": { + "error": { + "code": "FORBIDDEN", + "message": "forbidden" + } + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "6ec1cacb-9767-433e-ac0a-9b452e771ce6", + "status": "completed" + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "6ec1cacb-9767-433e-ac0a-9b452e771ce6", + "status": "completed" + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": false + } + } + ], + "checks": [ + "Verification credentials cannot erase users", + "Provider erasure completes", + "Erasure retry after restart returns the same job", + "Purchase has no account identity", + "App event copies are erased", + "Erased account has no access", + "A stale binding retry cannot restore identity" + ], + "totalChecks": 34 + } + ], + "screenshot": "screen.png", + "scope": "Incremental implementation of the installed Commerce Protocol contract. HTTP, SQLite and signatures execute locally; store and clock are fixtures. No full profile claim." +} diff --git a/docs/build/07-account-erasure-interoperable-2/screen.png b/docs/build/07-account-erasure-interoperable-2/screen.png new file mode 100644 index 0000000..982e657 Binary files /dev/null and b/docs/build/07-account-erasure-interoperable-2/screen.png differ diff --git a/docs/build/07-account-erasure-interoperable-2/source.tar.gz b/docs/build/07-account-erasure-interoperable-2/source.tar.gz new file mode 100644 index 0000000..7364c99 Binary files /dev/null and b/docs/build/07-account-erasure-interoperable-2/source.tar.gz differ diff --git a/docs/build/07-account-erasure-interoperable-3/attempt-1.txt b/docs/build/07-account-erasure-interoperable-3/attempt-1.txt new file mode 100644 index 0000000..375c2f5 --- /dev/null +++ b/docs/build/07-account-erasure-interoperable-3/attempt-1.txt @@ -0,0 +1,20 @@ +{ + "startedAt": "2026-09-08T19:49:13.476Z", + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 405ms\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && bun test composition/app-backend.test.mjs composition/receiver.test.mjs\n\nCommerce Protocol Example: 159 checks passed. No store or production service contacted.\nbun test v1.3.13 (bf2e2cec)\n\ncomposition/app-backend.test.mjs:\n(pass) account deletion waits for in-flight fulfillment and retries provider erasure after app restart [63.70ms]\n\ncomposition/receiver.test.mjs:\n(pass) the original SQLite and memory composition remains compatible with the receiver [37.37ms]\n(pass) one inbox scopes event IDs to authenticated emitters, survives upgrade, and discards erased users [3.08ms]\n\n 3 pass\n 0 fail\n 20 expect() calls\nRan 3 tests across 2 files. [174.00ms]\n" + }, + { + "command": "npm run test:tooling", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [11.47ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [57.60ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.58ms]\n(pass) unfinished captures do not hide completed records [1.10ms]\n(pass) checkpoint replay follows predecessors instead of directory name order [1.31ms]\n\n 5 pass\n 0 fail\nRan 5 tests across 1 file. [88.00ms]\n" + } + ] +} diff --git a/docs/build/07-account-erasure-interoperable-3/changes.patch b/docs/build/07-account-erasure-interoperable-3/changes.patch new file mode 100644 index 0000000..dbf64ee --- /dev/null +++ b/docs/build/07-account-erasure-interoperable-3/changes.patch @@ -0,0 +1,27 @@ +diff --git a/checkpoint.json b/checkpoint.json +index e139d74..4453b9a 100644 +--- a/checkpoint.json ++++ b/checkpoint.json +@@ -1,8 +1,8 @@ + { + "step": 7, +- "id": "07-account-erasure-interoperable-2", ++ "id": "07-account-erasure-interoperable-3", + "title": "Delete the account", + "built": "Account erasure with shared receiver and retry coordination", + "result": "The account identity is removed; retries and restart cannot restore it.", +- "previous": "07-account-erasure-interoperable" ++ "previous": "07-account-erasure-interoperable-2" + } +diff --git a/erasure.mjs b/erasure.mjs +index 90164eb..fb977b1 100644 +--- a/erasure.mjs ++++ b/erasure.mjs +@@ -1,6 +1,6 @@ + import { createHmac, randomBytes, randomUUID } from "node:crypto"; + +-// The random key prevents a database-only reader from guessing short user IDs. ++// Retain a stable retry marker without storing the user ID verbatim. + export function createErasureLedger(db) { + db.exec(` + PRAGMA secure_delete = ON; diff --git a/docs/build/07-account-erasure-interoperable-3/mobile.png b/docs/build/07-account-erasure-interoperable-3/mobile.png new file mode 100644 index 0000000..c5c65fa Binary files /dev/null and b/docs/build/07-account-erasure-interoperable-3/mobile.png differ diff --git a/docs/build/07-account-erasure-interoperable-3/run.json b/docs/build/07-account-erasure-interoperable-3/run.json new file mode 100644 index 0000000..6758625 --- /dev/null +++ b/docs/build/07-account-erasure-interoperable-3/run.json @@ -0,0 +1,911 @@ +{ + "step": 7, + "id": "07-account-erasure-interoperable-3", + "title": "Delete the account", + "built": "Account erasure with shared receiver and retry coordination", + "result": "The account identity is removed; retries and restart cannot restore it.", + "previous": "07-account-erasure-interoperable-2", + "startedAt": "2026-09-08T19:49:13.476Z", + "recordedAt": "2026-09-08T19:49:19.963Z", + "packageVersion": "0.1.0", + "task": "# Review the completed backend\n\nApply the Fable 5.1 max CLI review to the final checkpoint. Add the missing\nfirst-binding grant event in the same transaction as ownership, reject a\npremature expiry without consuming its observation, and verify both rollback\nand expiry boundaries. Correct test labels to describe what they exercise.\n\nRepair patch generation without rewriting historical source or screenshots.\nBuild patches from the preceding archived source and verify their hashes.\nInstall and test each archive outside the monorepo with npm. Capture the revised\nfinal screen on desktop and mobile, then export only matching source evidence.\n\nKeep the original six checkpoints as history. Explain their incomplete discovery\nand missing grant behavior; do not describe them as conformant providers. Keep\nall changes uncommitted for maintainer review.\n\nApply the second review: retain gate delivery state for delayed expiry,\nretain store occurrence on delayed binding, preserve consecutive failure logs,\nand state actual package contents and runtime requirements. Add a ready-to-run\ngeneric event receiver for an existing backend, reusing the same receiver\nhandler. Demonstrate signed lifecycle ingestion, duplicate delivery, tampering,\nand persisted inbox recovery over real local HTTP. Keep all samples fictional.\n\nAdd composable integration roles: experience, commerce, and data. Ship a short\nAI integration brief and a backend helper that maps Apple/Google OpenIAP\npurchase fields into the installed verification schema. Test invalid inputs\nand exclude client-supplied identity. Keep paywall UI APIs product-specific,\ncurrent IAPKit-only client helpers explicit, and store/device proof separate\nfrom local fixtures. Reuse the existing consumer and contract validators.\n\nThe first bridge test failed because store evidence was nested under an extra\n`evidence` key. Keep the failure output, use the installed input schema's\ntop-level `apple`/`google` members, and rerun that boundary check.\n\nApply the third CLI review. Make the integration brief reachable from the docs\nsite with absolute setup, source, and build-brief links. Accept signed webhook\ndelivery behind a reverse proxy that preserves the public Host header. Keep\nunfinished captures from blocking completed history, retain equal-time fixture\ntransitions, record the observed duplicate response, and configure Yarn's\nnode-modules linker. Preserve the proxy failure and rerun the checks.\n\nPrepare the standalone example for its first public commit. Rewrite the README\naround clone, run, inspect, choose a role, and verify. Put receiver and capture\nsetup in repository documentation so the example works before the docs site is\ndeployed. Preserve every earlier archive. Record this documentation revision,\nverify all source archives again, and export a stable current-source download.\nAdd CI that tests runtime, tooling, archives and the documentation export. This\nrevision is a local publication review, not another completed external review.\n\nFix the first Linux CI failure without rewriting historical recordings. macOS\nAppleDouble metadata must not count as source. Exclude it on extraction, omit it\nfrom new archives, and add a portable extraction regression test. Capture the\ncorrected tooling, verify every source revision, and rerun GitHub CI.\n\nCorrect the final CLI review finding: exercise cancellation at the expiry\nobservation timestamp so the check reaches the expired-state guard. Preserve\nthe earlier capture, record this revision, and verify its archive and patch.\n\nApply the Codex review: authenticate webhook body bytes before UTF-8 decoding.\nReject altered UTF-8 and inserted BOM bytes with an unchanged signature. Reject\nauthentically signed malformed UTF-8 before storage, and accept correctly signed\nUnicode and BOM bodies. Keep the reproduced failure, preserve old checkpoints,\nthen capture and verify the corrected revision.\n", + "sourceHashes": { + ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", + ".yarnrc.yml": "473e6def86fc03638120e0c01d0c8bbab095677256460fa4ea763e5d4697f270", + "AGENTS.md": "38764ea47552533ab5db55f7ade8c0025e2d7af9163715ce89f2d719f5475712", + "BUILD.md": "abad05223f40512522487d83337755d5c3222fda4614e5c2ea92f45d13be977d", + "INTEGRATE.md": "532f25bc83d96350381c8c801caf5837777624095a3f519053133dffdbee1cd8", + "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", + "README.md": "f46856f23a859637f4488074744dbc4f16a7a42999e27491aa832bac2089cbec", + "ai-task.md": "d000a11054750accc6bab8ddbf6d2fc6b674b12c3821ccbfd45729469923b8fe", + "capture.mjs": "4940347c92713f47965400fa9dca0908372ec2b3627612fccf40eb6b636a85a2", + "checkpoint-tools.mjs": "81de52062f97e23c97a445603a7e274258b4481c5d536274140af2211b9021b7", + "checkpoint-tools.test.mjs": "1ea348330db8dab85694ac79cfec76e9d3af1729263a6a05cae61e10e41b2175", + "checkpoint.json": "603a3920e8802b6e27637476081ab2b73b7b991a264140f50b164de0eb475dbe", + "client-bridge.mjs": "a0058579b02c2f3a770c7ff7b59dfe41ce50761361d9a0c7a2df96ab1b6b4acf", + "composition/README.md": "99e302c7365cf3969f61be2c466f31f5cf0928b2f3f0351e45907666f0459a44", + "composition/app-backend.mjs": "b770db0706cc8fef6cd3a3d730245b25f0cc5bc5253a1c053b0923da53d875af", + "composition/app-backend.test.mjs": "9fbfc7fdb393b95bdf803dfb77e44a5d4311e5812e33a987b371a6b4a32a3b41", + "composition/commerce-client.mjs": "f1f1a056bc76801df25111cb12fdcde501d507a5461ef8b15bfe2f57979cd03c", + "composition/export.mjs": "ccc84dee42d59b1d2b09f16aec2b77a774765dd6fab0f341b01f1043a2e88225", + "composition/memory-provider.mjs": "edf6a7567ead756dc4a5c15d5b5b14f4d4314db31e3aa3c01c8ec543754fcc31", + "composition/purchase-flow.mjs": "ce6f98426c36eab346e900ac06ed31f2d387e542c518a48c92a67f7bae147ec8", + "composition/receiver.test.mjs": "1559036d9f0804017eeff544ed0eef7d7328111515eaeb38f6787cba815a1151", + "composition/run.mjs": "0e7aa1c8c5cab79f06d9853fa5b91f0e15f0322b4574b5f1a97f1ea119fc9b14", + "consumer.mjs": "d8e168238270309157d52ca777fea30f1d37bae38b96e88148314c894bdb1b4c", + "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", + "dashboard.html": "50a19acc0810453fa2ef55f4e5e958b4ba428764fc5a5edc10e262f9c990e532", + "erasure.mjs": "2dddb3ffd929c9d05333e88193f7cb3e5874ef5b29e68c68a1b25997d09788ea", + "export-docs.mjs": "47cf25c7ba1942fb833ac6b496c1e06b4ae06a61f6328330c6a6a4337c164f84", + "package-lock.json": "6f5333cc45203d6a27fec82c9370a8c25fef7faab591467dd2ff3c79acd6ae1c", + "package.json": "396fc93b7d50240739af2b817c172ffab8692245bd919de3b26ee713b56d455b", + "provider.mjs": "e5c498cf01beedab756058f62cbeb5a533477ebb9da1b6064d48f4003e524094", + "scenario.mjs": "b8ff9024afd67701b5f70916a61fa7d538eaec281aa4b5cc660e1c205932a32d", + "server.mjs": "b0f185a3954fbe1d7a4dd15f0dc8638afcf04e386ec730ae17a15860784d9e36", + "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", + "verify-erasure.mjs": "65feaff2e8eb0a3247ecdbc0f511c8eef5e07a4c2c63155b07409ba7cb19be88", + "verify.mjs": "942f1cd3f663dd84350a20fba459f707436a82c691469caf2c2f36155fbd5b72", + "webhooks.mjs": "4838bac7a9083440998409aa1ab543999495ea6a907036bc78ba2002feb588b2" + }, + "checks": [ + "Fixture request matches the installed schema", + "Capabilities use the published response schema", + "Purchase storage starts empty", + "No profile conformance is claimed", + "Fixture evidence is accepted", + "Verification does not grant access", + "Invalid evidence produces a negative verdict", + "An upstream outage is not a negative verdict", + "Verification credentials cannot bind a user", + "The server binds Alice", + "Repeating the same binding succeeds", + "Bob cannot take Alice's purchase", + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none", + "Cancellation keeps paid access", + "Cancellation stops renewal", + "Cancellation queues one event", + "A 503 leaves a durable retry", + "Retry survives provider restart", + "Retry keeps the delivery identity", + "Redelivery has one inbox row per event", + "Access closes at expiry before a notification arrives", + "Duplicate store notification emits no extra event", + "Reopening both databases preserves state", + "Receiver deduplication survives restart", + "All four events reach the receiver", + "The final status is inactive", + "Signature vector: single-key", + "Receiver accepts vector: single-key", + "Signature vector: retry-after-backoff", + "Receiver accepts vector: retry-after-backoff", + "Signature vector: raw-utf8-body", + "Receiver accepts vector: raw-utf8-body", + "Signature vector: during-rotation", + "Receiver accepts vector: during-rotation", + "Signature vector: minimal-event-omits-extensions", + "Receiver accepts vector: minimal-event-omits-extensions", + "Receiver rejects: tampered-body", + "Receiver rejects: wrong-secret", + "Receiver rejects: timestamp-outside-tolerance", + "Receiver rejects: timestamp-not-in-signed-material", + "Receiver rejects: retry-reuses-first-signature", + "Receiver rejects: garbage-appended-to-valid-signature", + "Active: before expiry", + "Active: at expiry", + "Active: no deadline", + "InGracePeriod: before expiry", + "InGracePeriod: at expiry", + "InGracePeriod: no deadline", + "InBillingRetry: before expiry", + "InBillingRetry: at expiry", + "InBillingRetry: no deadline", + "Paused: before expiry", + "Paused: at expiry", + "Paused: no deadline", + "Expired: before expiry", + "Expired: at expiry", + "Expired: no deadline", + "Revoked: before expiry", + "Revoked: at expiry", + "Revoked: no deadline", + "Refunded: before expiry", + "Refunded: at expiry", + "Refunded: no deadline", + "Unknown: before expiry", + "Unknown: at expiry", + "Unknown: no deadline", + "FutureState: before expiry", + "FutureState: at expiry", + "FutureState: no deadline", + "Missing credentials are refused", + "Verification role cannot enumerate users", + "Malformed input is refused", + "A real store is not falsely accepted", + "Erasure rejects verification credentials", + "Cancellation after expiry is ignored", + "Expired fixture evidence has an expired verdict", + "Tampered HTTP body has no inbox effect", + "Changed UTF-8 bytes cannot reuse a signature", + "Inserted UTF-8 BOM cannot reuse a signature", + "Authenticated malformed UTF-8 is rejected before storage", + "Authentic Unicode bytes are accepted and stored", + "Authentic BOM bytes are verified before decoding", + "Receiver still has exactly four events", + "Cross-origin demo mutations are refused", + "Overlapping HTTP ownership claims have one winner", + "A cancellation older than the active row is ignored", + "Ignoring an old cancellation preserves renewal", + "Conflicting expiry is not consumed", + "Outbox failure rolls back subscription state", + "Failed transaction leaves the observation retryable", + "Exhausted retries enter dead-letter", + "Grant failure rejects binding", + "Grant failure rolls back ownership", + "An expired purchase can be bound", + "Binding expired evidence emits no grant", + "Delayed binding retains the store occurrence", + "Delayed binding records its actual processing time", + "Late expiry revokes a persisted grant exactly once", + "Repeated expiry emits no second revocation", + "Equal-time expiry retains its lifecycle transition", + "Equal-time observations revoke once and preserve both transitions", + "Health accepts the proxy public Host header", + "subscription.started: authenticated and saved", + "subscription.started: redelivery deduplicated", + "subscription.started: tampering rejected", + "entitlement.granted: authenticated and saved", + "entitlement.granted: redelivery deduplicated", + "entitlement.granted: tampering rejected", + "subscription.renewed: authenticated and saved", + "subscription.renewed: redelivery deduplicated", + "subscription.renewed: tampering rejected", + "subscription.canceled: authenticated and saved", + "subscription.canceled: redelivery deduplicated", + "subscription.canceled: tampering rejected", + "subscription.expired: authenticated and saved", + "subscription.expired: redelivery deduplicated", + "subscription.expired: tampering rejected", + "entitlement.revoked: authenticated and saved", + "entitlement.revoked: redelivery deduplicated", + "entitlement.revoked: tampering rejected", + "subscription.refunded: authenticated and saved", + "subscription.refunded: redelivery deduplicated", + "subscription.refunded: tampering rejected", + "One inbox record per event", + "Inbox survives reopening SQLite", + "apple: maps evidence without forwarding client identity", + "apple: matches the installed verification input schema", + "google: maps evidence without forwarding client identity", + "google: matches the installed verification input schema", + "Rejects missing purchase", + "Rejects unknown store", + "Rejects Amazon needs its own adapter", + "Rejects Horizon needs its own adapter", + "Rejects missing evidence", + "Rejects blank evidence", + "Rejects non-string evidence", + "Rejects oversized evidence", + "An active purchase has a delivered event copy", + "Erasure during delivery completes", + "An in-flight event cannot resurrect receiver data", + "An in-flight acknowledgement cannot resurrect the outbox", + "Repeated erase survives restart", + "An old signed event remains discarded after restart", + "A new event ID cannot bypass erasure", + "Verification cannot bind an erased purchase", + "Stale binding cannot restore an erased account", + "Another account cannot claim erased evidence", + "Erased account is inactive before paid expiry", + "Late lifecycle processing carries no erased identity", + "Persisted protocol records contain no erased user ID", + "Unknown-user erasure is accepted", + "Verification credentials cannot erase users", + "Provider erasure completes", + "Erasure retry after restart returns the same job", + "Purchase has no account identity", + "App event copies are erased", + "Erased account has no access", + "A stale binding retry cannot restore identity" + ], + "history": [ + { + "step": 1, + "title": "Start with the contract", + "built": "HTTP routes + schema validation + SQLite", + "result": "A running server, an empty purchase table, and no access.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "providerCapabilities", + "httpStatus": 200, + "body": { + "specVersion": "1.0", + "implementation": { + "name": "Commerce Protocol Example — fictional fixture store" + }, + "eventTypes": [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked" + ], + "stores": { + "fixture": { + "initialValidation": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "serverNotifications": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "subscriptions": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "renewalEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "refundEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "expiration": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "reconciliation": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "entitlements": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "revenueAmount": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + } + } + } + } + }, + { + "operation": "providerCapabilities", + "httpStatus": 200, + "body": { + "specVersion": "1.0", + "implementation": { + "name": "Commerce Protocol Example — fictional fixture store" + }, + "eventTypes": [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked" + ], + "stores": { + "fixture": { + "initialValidation": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "serverNotifications": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "subscriptions": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "renewalEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "refundEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "expiration": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "reconciliation": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "entitlements": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "revenueAmount": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + } + } + } + } + } + ], + "checks": [ + "Fixture request matches the installed schema", + "Capabilities use the published response schema", + "Purchase storage starts empty", + "No profile conformance is claimed" + ], + "totalChecks": 4 + }, + { + "step": 2, + "title": "Verify a purchase", + "built": "Fixture store adapter + purchase persistence", + "result": "Valid evidence is saved. Alice still has no access.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [ + { + "userId": null, + "productId": "premium.monthly", + "state": "Active", + "willRenew": 1 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "verifyPurchase", + "httpStatus": 200, + "body": { + "store": "fixture", + "isValid": true, + "state": "ENTITLED", + "productId": "premium.monthly", + "environment": "local-fixture" + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "verifyPurchase", + "httpStatus": 200, + "body": { + "store": "fixture", + "isValid": false, + "state": "INAUTHENTIC", + "environment": "local-fixture" + } + }, + { + "operation": "verifyPurchase", + "httpStatus": 502, + "body": { + "error": { + "code": "VERIFICATION_FAILED", + "message": "verification failed" + } + } + } + ], + "checks": [ + "Fixture evidence is accepted", + "Verification does not grant access", + "Invalid evidence produces a negative verdict", + "An upstream outage is not a negative verdict" + ], + "totalChecks": 8 + }, + { + "step": 3, + "title": "Connect it to a user", + "built": "Server authorization + atomic binding + entitlement reads", + "result": "Alice gets Premium. Another user cannot take the purchase.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 1 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": true + } + ] + }, + "deliveries": [ + { + "eventId": "d9b302c0-3aa1-45d9-85b2-77f6e2474625", + "deliveryId": "14a8259d-9aa3-475b-8c4a-0150671619b7", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + } + ], + "inboxCount": 0, + "responses": [ + { + "operation": "bindPurchase", + "httpStatus": 403, + "body": { + "error": { + "code": "FORBIDDEN", + "message": "forbidden" + } + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": true + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": true + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": false + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": true + } + ] + } + } + ], + "checks": [ + "Verification credentials cannot bind a user", + "The server binds Alice", + "Repeating the same binding succeeds", + "Bob cannot take Alice's purchase", + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none" + ], + "totalChecks": 14 + }, + { + "step": 4, + "title": "Handle cancellation", + "built": "Lifecycle processing + transactional event outbox", + "result": "Renewal stops. Alice keeps the time she already paid for.", + "simulatedTime": "2026-09-08T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + ] + }, + "deliveries": [ + { + "eventId": "d9b302c0-3aa1-45d9-85b2-77f6e2474625", + "deliveryId": "14a8259d-9aa3-475b-8c4a-0150671619b7", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + }, + { + "eventId": "7c42d8f0-adab-4d5b-8914-587f23bf1085", + "deliveryId": "f760314c-43e9-43e2-82c2-7e551ce65ab0", + "attempts": 0, + "status": "pending", + "eventType": "subscription.canceled" + } + ], + "inboxCount": 0, + "responses": [ + { + "operation": "subscriptionStatus", + "httpStatus": 200, + "body": { + "active": true, + "subscription": { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + } + } + ], + "checks": [ + "Cancellation keeps paid access", + "Cancellation stops renewal", + "Cancellation queues one event" + ], + "totalChecks": 17 + }, + { + "step": 5, + "title": "Deliver, retry, deduplicate", + "built": "HMAC signatures + retry worker + durable receiver inbox", + "result": "A 503 retries successfully. Redelivery creates no second inbox row.", + "simulatedTime": "2026-09-08T09:00:31.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + ] + }, + "deliveries": [ + { + "eventId": "d9b302c0-3aa1-45d9-85b2-77f6e2474625", + "deliveryId": "14a8259d-9aa3-475b-8c4a-0150671619b7", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "7c42d8f0-adab-4d5b-8914-587f23bf1085", + "deliveryId": "f760314c-43e9-43e2-82c2-7e551ce65ab0", + "attempts": 3, + "status": "delivered", + "eventType": "subscription.canceled" + } + ], + "inboxCount": 2, + "responses": [ + { + "operation": "webhook: receiver unavailable", + "body": [ + { + "eventId": "d9b302c0-3aa1-45d9-85b2-77f6e2474625", + "deliveryId": "14a8259d-9aa3-475b-8c4a-0150671619b7", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + }, + { + "eventId": "7c42d8f0-adab-4d5b-8914-587f23bf1085", + "deliveryId": "f760314c-43e9-43e2-82c2-7e551ce65ab0", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + } + ] + }, + { + "operation": "webhook: retry after restart", + "body": [ + { + "eventId": "d9b302c0-3aa1-45d9-85b2-77f6e2474625", + "deliveryId": "14a8259d-9aa3-475b-8c4a-0150671619b7", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + }, + { + "eventId": "7c42d8f0-adab-4d5b-8914-587f23bf1085", + "deliveryId": "f760314c-43e9-43e2-82c2-7e551ce65ab0", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + } + ] + }, + { + "operation": "webhook: lost-ack redelivery", + "body": [ + { + "eventId": "d9b302c0-3aa1-45d9-85b2-77f6e2474625", + "deliveryId": "14a8259d-9aa3-475b-8c4a-0150671619b7", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + }, + { + "eventId": "7c42d8f0-adab-4d5b-8914-587f23bf1085", + "deliveryId": "f760314c-43e9-43e2-82c2-7e551ce65ab0", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + } + ] + } + ], + "checks": [ + "A 503 leaves a durable retry", + "Retry survives provider restart", + "Retry keeps the delivery identity", + "Redelivery has one inbox row per event" + ], + "totalChecks": 21 + }, + { + "step": 6, + "title": "Expire access and restart", + "built": "Expiry-aware reads + recovery from SQLite", + "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", + "simulatedTime": "2026-10-07T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Expired", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [ + { + "eventId": "d9b302c0-3aa1-45d9-85b2-77f6e2474625", + "deliveryId": "14a8259d-9aa3-475b-8c4a-0150671619b7", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "7c42d8f0-adab-4d5b-8914-587f23bf1085", + "deliveryId": "f760314c-43e9-43e2-82c2-7e551ce65ab0", + "attempts": 3, + "status": "delivered", + "eventType": "subscription.canceled" + }, + { + "eventId": "e382a6ab-debb-4dd9-b79f-be60b77a3459", + "deliveryId": "8df65a8f-3a0d-409d-bc02-ad79a4c12476", + "attempts": 1, + "status": "delivered", + "eventType": "subscription.expired" + }, + { + "eventId": "72dc8fdf-09ec-4143-88ed-ac812c168306", + "deliveryId": "c0aa812b-1cdf-42a5-a676-69974af52ade", + "attempts": 1, + "status": "delivered", + "eventType": "entitlement.revoked" + } + ], + "inboxCount": 4, + "responses": [ + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "webhook: expiry + revocation", + "body": [ + { + "eventId": "e382a6ab-debb-4dd9-b79f-be60b77a3459", + "deliveryId": "8df65a8f-3a0d-409d-bc02-ad79a4c12476", + "httpStatus": 200, + "attempt": 1, + "status": "delivered" + }, + { + "eventId": "72dc8fdf-09ec-4143-88ed-ac812c168306", + "deliveryId": "c0aa812b-1cdf-42a5-a676-69974af52ade", + "httpStatus": 200, + "attempt": 1, + "status": "delivered" + } + ] + }, + { + "operation": "subscriptionStatus", + "httpStatus": 200, + "body": { + "active": false, + "subscription": { + "productId": "premium.monthly", + "state": "Expired", + "active": false, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + } + } + ], + "checks": [ + "Access closes at expiry before a notification arrives", + "Duplicate store notification emits no extra event", + "Reopening both databases preserves state", + "Receiver deduplication survives restart", + "All four events reach the receiver", + "The final status is inactive" + ], + "totalChecks": 27 + }, + { + "step": 7, + "title": "Delete the account", + "built": "Idempotent erasure + receiver cleanup + durable deletion guard", + "result": "Alice is removed from purchases and event copies. Late deliveries cannot restore her account data.", + "simulatedTime": "2026-10-07T09:00:00.000Z", + "purchases": [ + { + "userId": null, + "productId": "premium.monthly", + "state": "Expired", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "eraseUser", + "httpStatus": 403, + "body": { + "error": { + "code": "FORBIDDEN", + "message": "forbidden" + } + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "cc21a42c-51bc-48cc-8222-8deb5ab7e10c", + "status": "completed" + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "cc21a42c-51bc-48cc-8222-8deb5ab7e10c", + "status": "completed" + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": false + } + } + ], + "checks": [ + "Verification credentials cannot erase users", + "Provider erasure completes", + "Erasure retry after restart returns the same job", + "Purchase has no account identity", + "App event copies are erased", + "Erased account has no access", + "A stale binding retry cannot restore identity" + ], + "totalChecks": 34 + } + ], + "screenshot": "screen.png", + "scope": "Incremental implementation of the installed Commerce Protocol contract. HTTP, SQLite and signatures execute locally; store and clock are fixtures. No full profile claim." +} diff --git a/docs/build/07-account-erasure-interoperable-3/screen.png b/docs/build/07-account-erasure-interoperable-3/screen.png new file mode 100644 index 0000000..982e657 Binary files /dev/null and b/docs/build/07-account-erasure-interoperable-3/screen.png differ diff --git a/docs/build/07-account-erasure-interoperable-3/source.tar.gz b/docs/build/07-account-erasure-interoperable-3/source.tar.gz new file mode 100644 index 0000000..683d94e Binary files /dev/null and b/docs/build/07-account-erasure-interoperable-3/source.tar.gz differ diff --git a/docs/build/07-account-erasure-interoperable-4/attempt-1.txt b/docs/build/07-account-erasure-interoperable-4/attempt-1.txt new file mode 100644 index 0000000..4b86a74 --- /dev/null +++ b/docs/build/07-account-erasure-interoperable-4/attempt-1.txt @@ -0,0 +1,20 @@ +{ + "startedAt": "2026-09-08T20:53:50.264Z", + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 433ms\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && bun test composition/app-backend.test.mjs composition/receiver.test.mjs\n\nCommerce Protocol Example: 213 checks passed. No store or production service contacted.\nbun test v1.3.13 (bf2e2cec)\n\ncomposition/app-backend.test.mjs:\n(pass) account deletion waits for in-flight fulfillment and retries provider erasure after app restart [65.19ms]\n\ncomposition/receiver.test.mjs:\n(pass) the original SQLite and memory composition remains compatible with the receiver [39.13ms]\n(pass) one inbox scopes event IDs to authenticated emitters, survives upgrade, and discards erased users [5.20ms]\n\n 3 pass\n 0 fail\n 20 expect() calls\nRan 3 tests across 2 files. [174.00ms]\n" + }, + { + "command": "npm run test:tooling", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [11.12ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [46.39ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.45ms]\n(pass) unfinished captures do not hide completed records [0.71ms]\n(pass) checkpoint replay follows predecessors instead of directory name order [1.11ms]\n\n 5 pass\n 0 fail\nRan 5 tests across 1 file. [74.00ms]\n" + } + ] +} diff --git a/docs/build/07-account-erasure-interoperable-4/changes.patch b/docs/build/07-account-erasure-interoperable-4/changes.patch new file mode 100644 index 0000000..059660e --- /dev/null +++ b/docs/build/07-account-erasure-interoperable-4/changes.patch @@ -0,0 +1,639 @@ +diff --git a/BUILD.md b/BUILD.md +index 94a7142..f9c0629 100644 +--- a/BUILD.md ++++ b/BUILD.md +@@ -109,6 +109,15 @@ Check the visible app after purchase, cancellation, expiry, reload, and deletion + operations. Include the passing local conformance report and its fixture scope. Keep the main explanation short; + link the specification for details. + ++## Match the selected store ++ ++Read the store table in `INTEGRATE.md` before replacing the fixture. Exercise ++all evidence shapes the provider advertises. Keep the app account distinct ++from the Amazon/Meta store user, and reject a claim for someone else's store ++account. Recheck ownership for Amazon/Horizon access; never invent subscription ++or notification support to make their flow look like Apple/Google. Prove ++negative rechecks, outages, conflicting bindings, erasure, and restart. ++ + ## Then extend toward a production provider + + Ask me which real store, backend identity system, and deployment environment to +diff --git a/INTEGRATE.md b/INTEGRATE.md +index ba56c4a..7cfb8c3 100644 +--- a/INTEGRATE.md ++++ b/INTEGRATE.md +@@ -23,7 +23,7 @@ Use your favorite package manager: `npm install`, `pnpm install`, `yarn install` + or `bun install`. This example's runtime is Bun. The contract is + `openiap-commerce-protocol` package 0.1.0, protocol 1.0; it does not require Bun. + +-- `npm run demo:bridge`: maps Apple/Google OpenIAP purchase fields into the ++- `npm run demo:bridge`: maps Apple, Google, Amazon, and Horizon OpenIAP purchase fields into the + installed verification schema; rejects missing or unsupported evidence. + - `npm run demo:consumer`: sends signed lifecycle events to a SQLite inbox over + HTTP, repeats deliveries, rejects tampering, and reopens persisted storage. +@@ -62,7 +62,7 @@ for the app team. Follow these boundaries: + UI, targeting, or product catalog API; document this host adapter explicitly. + 2. **App connection:** the app uses its OpenIAP library to fetch products and + request a store purchase. Its purchase callback sends evidence to its +- authenticated backend. Use `client-bridge.mjs` there to map Apple/Google ++ authenticated backend. Use `client-bridge.mjs` there to map Apple, Google, Amazon, and Horizon + purchase fields into a verification input; this does not authenticate the + evidence. Keep server keys and user selection on that backend. Verify, bind + under the ownership policy, read current access, fulfill durably, then finish +@@ -83,9 +83,48 @@ for the app team. Follow these boundaries: + + The current client `verifyPurchaseWithProvider` helper supports IAPKit's own + API. A different provider name or base URL does not turn it into this protocol. +-Other providers connect through the app backend's REST or GraphQL calls. The +-Apple/Google helper does not support Amazon or Horizon, whose protocol evidence +-requires store-specific user identifiers distinct from the app's user ID. ++Other providers connect through the app backend's REST or GraphQL calls. Amazon and Horizon require a store-specific user identifier distinct from the ++app user ID. Pass it as `context.storeUserId` to the bridge after authenticating ++the store account link. `startAppBackend` requires `resolveStoreUser` for these ++stores and rejects evidence belonging to a different store account. Never ++implement that callback by copying a user ID from the request body. ++ ++## Select the store before implementing ++ ++The same six protocol operations carry these four evidence shapes: ++ ++| Store | Purchase evidence | IAPKit access path | ++| --- | --- | --- | ++| Apple | `apple.jws` from the store purchase | Bind the verified subscription; read its current state and listen for lifecycle events | ++| Google | `google.purchaseToken` | Bind the verified subscription; read its current state and listen for lifecycle events | ++| Amazon | `amazon.userId`, `amazon.receiptId`, optional `amazon.sandbox` | Bind the verified receipt; each entitlement read rechecks RVS | ++| Meta Horizon | `horizon.userId`, `horizon.sku` | Bind the verified store-user/SKU pair; each entitlement read rechecks Meta | ++ ++For Amazon and Horizon, use `entitlements.productIds` for access. IAPKit does ++not invent a subscription record, expiry date, or lifecycle event for these ++ownership checks. An empty `subscriptions` list can accompany owned products. ++A negative store answer removes the product; a failed store call fails the ++read. Decide caching and outage policy in the app backend. Reads currently ++fail if an account has more than 20 linked Amazon/Horizon purchase rows. ++ ++For Quest, verify Meta's user proof on your authenticated backend before linking ++that Meta user to the app account. Follow the official ++[Meta user verification guide](https://developers.meta.com/horizon/documentation/android-apps/ps-ownership/). ++For Amazon, establish the store account association through your application's ++trusted sign-in and ownership policy. Receipt possession alone does not prove ++which app account may claim it. The runnable comparison uses explicit fictional ++session links; it does not implement your authentication provider. ++ ++Keep consumable fulfillment separate: record each granted unit durably and ++idempotently before finishing/consuming. A verified SKU is not a new quantity ++to credit on every read. The protocol walkthrough demonstrates Premium access; ++it does not implement a wallet or sell a Nami paywall. ++ ++For IAPKit setup, configure Apple bundle/App ID and Server API signing key, ++Google package and service account, Meta App ID/secret, or Amazon RVS shared ++secret in the project. Keep secrets on the server. Enable Amazon sandbox only ++for App Tester evidence. The local comparison requires none of these real ++credentials; its external store responses are fixtures. + + ## Deliver and prove the connection + +diff --git a/README.md b/README.md +index a11d8e0..c806211 100644 +--- a/README.md ++++ b/README.md +@@ -60,8 +60,10 @@ If port 5181 is occupied, run `COMMERCE_LAB_PORT=5183 npm start`. + | Data / automation | [Event receiver guide](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/receiver.md) | A ready signed-event receiver with a durable inbox | + | Integrated platform | [Integration brief](INTEGRATE.md) | How the roles compose without splitting account authority | + +-`client-bridge.mjs` maps Apple/Google OpenIAP purchase fields into the installed ++`client-bridge.mjs` maps Apple, Google, Amazon, and Horizon OpenIAP purchase fields into the installed + verification schema **on the app backend**. Run `npm run demo:bridge` to check it. ++`npm test` also exercises all four fixture shapes through verification, binding, ++access and erasure. Amazon/Horizon cases include negative rechecks and outages. + It does not perform a mobile purchase or authenticate store evidence. The + current client `verifyPurchaseWithProvider` helper uses IAPKit's own API; other + providers connect through the app's authenticated backend. +diff --git a/ai-task.md b/ai-task.md +index 7a2e80f..e6aa254 100644 +--- a/ai-task.md ++++ b/ai-task.md +@@ -1,63 +1,13 @@ +-# Review the completed backend +- +-Apply the Fable 5.1 max CLI review to the final checkpoint. Add the missing +-first-binding grant event in the same transaction as ownership, reject a +-premature expiry without consuming its observation, and verify both rollback +-and expiry boundaries. Correct test labels to describe what they exercise. +- +-Repair patch generation without rewriting historical source or screenshots. +-Build patches from the preceding archived source and verify their hashes. +-Install and test each archive outside the monorepo with npm. Capture the revised +-final screen on desktop and mobile, then export only matching source evidence. +- +-Keep the original six checkpoints as history. Explain their incomplete discovery +-and missing grant behavior; do not describe them as conformant providers. Keep +-all changes uncommitted for maintainer review. +- +-Apply the second review: retain gate delivery state for delayed expiry, +-retain store occurrence on delayed binding, preserve consecutive failure logs, +-and state actual package contents and runtime requirements. Add a ready-to-run +-generic event receiver for an existing backend, reusing the same receiver +-handler. Demonstrate signed lifecycle ingestion, duplicate delivery, tampering, +-and persisted inbox recovery over real local HTTP. Keep all samples fictional. +- +-Add composable integration roles: experience, commerce, and data. Ship a short +-AI integration brief and a backend helper that maps Apple/Google OpenIAP +-purchase fields into the installed verification schema. Test invalid inputs +-and exclude client-supplied identity. Keep paywall UI APIs product-specific, +-current IAPKit-only client helpers explicit, and store/device proof separate +-from local fixtures. Reuse the existing consumer and contract validators. +- +-The first bridge test failed because store evidence was nested under an extra +-`evidence` key. Keep the failure output, use the installed input schema's +-top-level `apple`/`google` members, and rerun that boundary check. +- +-Apply the third CLI review. Make the integration brief reachable from the docs +-site with absolute setup, source, and build-brief links. Accept signed webhook +-delivery behind a reverse proxy that preserves the public Host header. Keep +-unfinished captures from blocking completed history, retain equal-time fixture +-transitions, record the observed duplicate response, and configure Yarn's +-node-modules linker. Preserve the proxy failure and rerun the checks. +- +-Prepare the standalone example for its first public commit. Rewrite the README +-around clone, run, inspect, choose a role, and verify. Put receiver and capture +-setup in repository documentation so the example works before the docs site is +-deployed. Preserve every earlier archive. Record this documentation revision, +-verify all source archives again, and export a stable current-source download. +-Add CI that tests runtime, tooling, archives and the documentation export. This +-revision is a local publication review, not another completed external review. +- +-Fix the first Linux CI failure without rewriting historical recordings. macOS +-AppleDouble metadata must not count as source. Exclude it on extraction, omit it +-from new archives, and add a portable extraction regression test. Capture the +-corrected tooling, verify every source revision, and rerun GitHub CI. +- +-Correct the final CLI review finding: exercise cancellation at the expiry +-observation timestamp so the check reaches the expired-state guard. Preserve +-the earlier capture, record this revision, and verify its archive and patch. +- +-Apply the Codex review: authenticate webhook body bytes before UTF-8 decoding. +-Reject altered UTF-8 and inserted BOM bytes with an unchanged signature. Reject +-authentically signed malformed UTF-8 before storage, and accept correctly signed +-Unicode and BOM bodies. Keep the reproduced failure, preserve old checkpoints, +-then capture and verify the corrected revision. ++# Cover the four IAPKit store boundaries ++ ++Map Apple, Google, Amazon and Meta Horizon evidence into the installed protocol. ++Keep store identity distinct from the app session; Amazon and Horizon claims ++must pass the host's authenticated store-account link. Add matching fixture ++provider paths and execute ownership, rejection, outage and erasure cases. ++Compare the same application backend with IAPKit's actual handlers and local ++Convex storage. Mark external store calls as fixtures and do not invent a ++subscription lifecycle or a consumable wallet from a SKU ownership result. ++ ++Run npm test and the provider comparison, capture the current source, and verify ++it from an empty directory. Preserve the previous checkpoint. Keep all changes ++uncommitted for maintainer review. +diff --git a/checkpoint-tools.mjs b/checkpoint-tools.mjs +index 61adf66..8ef96bc 100644 +--- a/checkpoint-tools.mjs ++++ b/checkpoint-tools.mjs +@@ -31,6 +31,7 @@ export const SOURCE_FILES = [ + "provider.mjs", + "erasure.mjs", + "verify-erasure.mjs", ++ "verify-stores.mjs", + "webhooks.mjs", + "consumer.mjs", + "client-bridge.mjs", +diff --git a/checkpoint.json b/checkpoint.json +index 4453b9a..9aad24e 100644 +--- a/checkpoint.json ++++ b/checkpoint.json +@@ -1,8 +1,8 @@ + { + "step": 7, +- "id": "07-account-erasure-interoperable-3", ++ "id": "07-account-erasure-interoperable-4", + "title": "Delete the account", +- "built": "Account erasure with shared receiver and retry coordination", +- "result": "The account identity is removed; retries and restart cannot restore it.", +- "previous": "07-account-erasure-interoperable-2" ++ "built": "Apple, Google, Amazon and Horizon evidence, ownership checks and account erasure", ++ "result": "The same account flow covers four store evidence shapes; Amazon and Horizon access is rechecked.", ++ "previous": "07-account-erasure-interoperable-3" + } +diff --git a/client-bridge.mjs b/client-bridge.mjs +index 1d5d64a..248d314 100644 +--- a/client-bridge.mjs ++++ b/client-bridge.mjs +@@ -2,18 +2,43 @@ import assert from "node:assert/strict"; + import { operation, validate } from "./contract.mjs"; + + // Run on the app backend; the provider still authenticates the store evidence. +-export function toVerifyPurchaseInput(purchase) { ++export function toVerifyPurchaseInput(purchase, context = {}) { + const { store, purchaseToken } = purchase ?? {}; +- if (!["apple", "google"].includes(store)) +- throw new Error("This adapter supports Apple and Google purchases only"); +- if (typeof purchaseToken !== "string" || !purchaseToken.trim()) +- throw new Error("Purchase evidence is required"); +- const input = { +- store, +- ...(store === "apple" +- ? { apple: { jws: purchaseToken } } +- : { google: { purchaseToken } }), ++ let input; ++ const required = (value) => { ++ if (typeof value !== "string" || !value.trim()) ++ throw new Error("Purchase evidence is required"); ++ return value; + }; ++ switch (store) { ++ case "apple": ++ input = { store, apple: { jws: required(purchaseToken) } }; ++ break; ++ case "google": ++ input = { store, google: { purchaseToken: required(purchaseToken) } }; ++ break; ++ case "amazon": ++ input = { ++ store, ++ amazon: { ++ userId: required(context.storeUserId), ++ receiptId: required(purchaseToken), ++ ...(context.amazonSandbox === true ? { sandbox: true } : {}), ++ }, ++ }; ++ break; ++ case "horizon": ++ input = { ++ store, ++ horizon: { ++ userId: required(context.storeUserId), ++ sku: required(purchase.productId), ++ }, ++ }; ++ break; ++ default: ++ throw new Error("Unsupported purchase store"); ++ } + if (!validate(operation("verifyPurchase").input, input)) + throw new Error("Purchase evidence does not match the protocol input"); + return input; +@@ -39,15 +64,50 @@ export function runBridgeDemo() { + assert(validate(operation("verifyPurchase").input, input)); + checks.push(`${store}: matches the installed verification input schema`); + } ++ for (const store of ["amazon", "horizon"]) { ++ const input = toVerifyPurchaseInput( ++ { ++ store, ++ purchaseToken: "receipt-1", ++ productId: "premium.monthly", ++ userId: "untrusted-app-user", ++ }, ++ { storeUserId: "authenticated-store-user", amazonSandbox: true }, ++ ); ++ assert.deepEqual( ++ input, ++ store === "amazon" ++ ? { ++ store, ++ amazon: { ++ userId: "authenticated-store-user", ++ receiptId: "receipt-1", ++ sandbox: true, ++ }, ++ } ++ : { ++ store, ++ horizon: { ++ userId: "authenticated-store-user", ++ sku: "premium.monthly", ++ }, ++ }, ++ ); ++ checks.push( ++ `${store}: uses server-selected store identity, distinct from the app user`, ++ ); ++ assert(validate(operation("verifyPurchase").input, input)); ++ checks.push(`${store}: matches the installed verification input schema`); ++ } + for (const [label, purchase] of [ + ["missing purchase", null], + ["unknown store", { store: "unknown", purchaseToken: "fictional" }], + [ +- "Amazon needs its own adapter", ++ "Amazon requires its authenticated store user", + { store: "amazon", purchaseToken: "fictional" }, + ], + [ +- "Horizon needs its own adapter", ++ "Horizon requires its authenticated store user", + { store: "horizon", purchaseToken: "fictional" }, + ], + ["missing evidence", { store: "apple" }], +diff --git a/composition/app-backend.mjs b/composition/app-backend.mjs +index 1d63f4f..5803dbb 100644 +--- a/composition/app-backend.mjs ++++ b/composition/app-backend.mjs +@@ -3,7 +3,13 @@ import { createCommerceClient } from "./commerce-client.mjs"; + import { createErasureLedger } from "../erasure.mjs"; + + // resolveSession is the host app's authentication boundary, supplied by the caller. +-export function startAppBackend({ path, providers, receiver, resolveSession }) { ++export function startAppBackend({ ++ path, ++ providers, ++ receiver, ++ resolveSession, ++ resolveStoreUser, ++}) { + const db = new Database(path, { create: true }); + db.exec( + "CREATE TABLE IF NOT EXISTS erasure_requests (user_id TEXT, provider TEXT, PRIMARY KEY(user_id, provider))", +@@ -48,6 +54,16 @@ export function startAppBackend({ path, providers, receiver, resolveSession }) { + try { + if (url.pathname === "/purchase" && request.method === "POST") { + const input = await request.json(); ++ if (erased.has(userId)) ++ return new Response("Unauthenticated", { status: 401 }); ++ if (input.store === "amazon" || input.store === "horizon") { ++ const storeUser = await resolveStoreUser?.(request, input.store); ++ if (!storeUser || input[input.store]?.userId !== storeUser) ++ return new Response( ++ "Store account is not linked to this session", ++ { status: 403 }, ++ ); ++ } + if (erased.has(userId)) + return new Response("Unauthenticated", { status: 401 }); + const work = clients[selected].fulfill(input, { +diff --git a/provider.mjs b/provider.mjs +index 37c5b19..3a50693 100644 +--- a/provider.mjs ++++ b/provider.mjs +@@ -31,9 +31,28 @@ export function isEntitled(state, expiresAt, now) { + + // This adapter recognizes one fictional purchase; it never contacts a store. + function fixtureEvidence(input) { +- return input.store === "google" +- ? input.google?.purchaseToken +- : input.evidence; ++ switch (input.store) { ++ case "apple": ++ return input.apple?.jws; ++ case "google": ++ return input.google?.purchaseToken; ++ case "amazon": ++ return ( ++ input.amazon && ++ JSON.stringify([ ++ input.amazon.userId, ++ input.amazon.receiptId, ++ input.amazon.sandbox === true, ++ ]) ++ ); ++ case "horizon": ++ return ( ++ input.horizon && ++ JSON.stringify([input.horizon.userId, input.horizon.sku]) ++ ); ++ default: ++ return input.evidence; ++ } + } + + function verifyFixture(input, fixture) { +@@ -43,7 +62,9 @@ function verifyFixture(input, fixture) { + if (evidence === "local-upstream-outage") { + return { error: "VERIFICATION_FAILED" }; + } +- return { accepted: evidence === fixture.evidence }; ++ const current = fixture.currentVerdict?.(); ++ if (current === "outage") return { error: "VERIFICATION_FAILED" }; ++ return { accepted: evidence === fixture.evidence && current !== false }; + } + + export function createProvider(path, now, fixture = FIXTURE) { +@@ -89,6 +110,18 @@ export function createProvider(path, now, fixture = FIXTURE) { + } + + function entitlements(userId) { ++ if (fixture.pointInTime) { ++ const current = fixture.currentVerdict?.(); ++ if (current === "outage") return { error: "VERIFICATION_FAILED" }; ++ return { ++ userId, ++ productIds: ++ current === false ++ ? [] ++ : [...new Set(rowsFor(userId).map((row) => row.product_id))], ++ subscriptions: [], ++ }; ++ } + const subscriptions = rowsFor(userId) + .map(snapshot) + .filter((row) => row.active); +@@ -126,8 +159,14 @@ export function createProvider(path, now, fixture = FIXTURE) { + capabilityNames.map((key) => [ + key, + { +- provider: supported.has(key), +- implementation: supported.has(key), ++ provider: ++ supported.has(key) && ++ (!fixture.pointInTime || ++ ["initialValidation", "entitlements"].includes(key)), ++ implementation: ++ supported.has(key) && ++ (!fixture.pointInTime || ++ ["initialValidation", "entitlements"].includes(key)), + notes: + "Local fixture demonstration only; no real store integration or profile conformance claim.", + }, +@@ -200,7 +239,11 @@ export function createProvider(path, now, fixture = FIXTURE) { + const row = db + .query("SELECT * FROM purchases WHERE fingerprint = ?") + .get(key); +- if (updated.changes && isEntitled(row.state, row.expires_at, now())) { ++ if ( ++ updated.changes && ++ !fixture.pointInTime && ++ isEntitled(row.state, row.expires_at, now()) ++ ) { + enqueue("entitlement.granted", row, row.observed_at); + db.query( + "UPDATE purchases SET entitlement_granted = 1 WHERE fingerprint = ?", +@@ -224,6 +267,7 @@ export function createProvider(path, now, fixture = FIXTURE) { + })(); + }, + subscriptionStatus(input) { ++ if (fixture.pointInTime) return { active: false }; + const snapshots = rowsFor(input.userId).map(snapshot); + const subscription = snapshots.find((row) => row.active) ?? snapshots[0]; + return { +diff --git a/verify-stores.mjs b/verify-stores.mjs +new file mode 100644 +index 0000000..30d87ff +--- /dev/null ++++ b/verify-stores.mjs +@@ -0,0 +1,142 @@ ++import assert from "node:assert/strict"; ++import { createProvider, CREDENTIALS } from "./provider.mjs"; ++import { operation } from "./contract.mjs"; ++import { toVerifyPurchaseInput } from "./client-bridge.mjs"; ++ ++export async function verifyStores() { ++ const checks = []; ++ for (const store of ["apple", "google", "amazon", "horizon"]) { ++ let current = true; ++ const input = toVerifyPurchaseInput( ++ { store, purchaseToken: "fictional-proof", productId: "premium.monthly" }, ++ { storeUserId: "fixture-store-user", amazonSandbox: true }, ++ ); ++ const evidence = ++ store === "amazon" ++ ? JSON.stringify(["fixture-store-user", "fictional-proof", true]) ++ : store === "horizon" ++ ? JSON.stringify(["fixture-store-user", "premium.monthly"]) ++ : "fictional-proof"; ++ const fixture = { ++ store, ++ evidence, ++ userId: "alice", ++ productId: "premium.monthly", ++ startsAt: Date.now(), ++ expiresAt: Date.now() + 60000, ++ pointInTime: ["amazon", "horizon"].includes(store), ++ currentVerdict: () => current, ++ }; ++ const provider = createProvider(":memory:", Date.now, fixture); ++ const check = (label, actual, expected) => { ++ assert.deepEqual(actual, expected, `${store}: ${label}`); ++ checks.push(`${store}: ${label}`); ++ }; ++ async function call(name, body, credential = CREDENTIALS.server) { ++ const spec = operation(name), ++ url = new URL(spec.path, "http://fixture.invalid"); ++ if (spec.method === "GET") ++ for (const [key, value] of Object.entries(body ?? {})) ++ url.searchParams.set(key, value); ++ const response = await provider.fetch( ++ new Request(url, { ++ method: spec.method, ++ headers: { ++ authorization: credential, ++ "content-type": "application/json", ++ }, ++ ...(spec.method === "POST" ? { body: JSON.stringify(body) } : {}), ++ }), ++ ); ++ return { status: response.status, result: await response.json() }; ++ } ++ try { ++ check( ++ "unverified evidence cannot bind", ++ (await call("bindPurchase", { ...input, userId: "alice" })).result ++ .bound, ++ false, ++ ); ++ check( ++ "matching evidence verifies", ++ (await call("verifyPurchase", input)).result.isValid, ++ true, ++ ); ++ check( ++ "verification alone gives no access", ++ (await call("entitlements", { userId: "alice" })).result.productIds, ++ [], ++ ); ++ check( ++ "verification credentials cannot bind", ++ ( ++ await call( ++ "bindPurchase", ++ { ...input, userId: "alice" }, ++ CREDENTIALS.verification, ++ ) ++ ).status, ++ 403, ++ ); ++ for (let i = 0; i < 2; i++) ++ check( ++ "binding and retry keep one owner", ++ (await call("bindPurchase", { ...input, userId: "alice" })).result ++ .bound, ++ true, ++ ); ++ check( ++ "another account cannot claim the purchase", ++ (await call("bindPurchase", { ...input, userId: "bob" })).result.bound, ++ false, ++ ); ++ check( ++ "owned product is accessible", ++ (await call("entitlements", { userId: "alice" })).result.productIds, ++ ["premium.monthly"], ++ ); ++ if (fixture.pointInTime) { ++ current = "outage"; ++ check( ++ "an outage is an error, not cached access", ++ (await call("entitlements", { userId: "alice" })).status, ++ 502, ++ ); ++ current = false; ++ check( ++ "a negative recheck removes access", ++ (await call("entitlements", { userId: "alice" })).result.productIds, ++ [], ++ ); ++ current = true; ++ check( ++ "a confirmed recheck restores ownership", ++ (await call("entitlements", { userId: "alice" })).result.productIds, ++ ["premium.monthly"], ++ ); ++ } ++ check( ++ "erasure completes", ++ (await call("eraseUser", { userId: "alice" })).result.status, ++ "completed", ++ ); ++ check( ++ "erasure removes access", ++ (await call("entitlements", { userId: "alice" })).result.productIds, ++ [], ++ ); ++ check( ++ "erased evidence cannot be claimed", ++ (await call("bindPurchase", { ...input, userId: "bob" })).result.bound, ++ false, ++ ); ++ } finally { ++ provider.close(); ++ } ++ } ++ return checks; ++} ++if (import.meta.main) ++ console.log( ++ `Store fixtures: ${(await verifyStores()).length} checks passed. No store contacted.`, ++ ); +diff --git a/verify.mjs b/verify.mjs +index 060c6f2..424d7dd 100644 +--- a/verify.mjs ++++ b/verify.mjs +@@ -8,6 +8,7 @@ import { runConsumerDemo } from "./consumer.mjs"; + import { runBridgeDemo } from "./client-bridge.mjs"; + import { startLab } from "./server.mjs"; + import { authentic, createReceiver, deliver, sign } from "./webhooks.mjs"; ++import { verifyStores } from "./verify-stores.mjs"; + import { verifyErasure } from "./verify-erasure.mjs"; + + export async function verifyLab({ compareSigner } = {}) { +@@ -427,6 +428,7 @@ export async function verifyLab({ compareSigner } = {}) { + } + checks.push(...(await runConsumerDemo()).checks); + checks.push(...runBridgeDemo()); ++ checks.push(...(await verifyStores())); + checks.push(...(await verifyErasure())); + return checks; + } diff --git a/docs/build/07-account-erasure-interoperable-4/mobile.png b/docs/build/07-account-erasure-interoperable-4/mobile.png new file mode 100644 index 0000000..c5c65fa Binary files /dev/null and b/docs/build/07-account-erasure-interoperable-4/mobile.png differ diff --git a/docs/build/07-account-erasure-interoperable-4/run.json b/docs/build/07-account-erasure-interoperable-4/run.json new file mode 100644 index 0000000..970b7ba --- /dev/null +++ b/docs/build/07-account-erasure-interoperable-4/run.json @@ -0,0 +1,966 @@ +{ + "step": 7, + "id": "07-account-erasure-interoperable-4", + "title": "Delete the account", + "built": "Apple, Google, Amazon and Horizon evidence, ownership checks and account erasure", + "result": "The same account flow covers four store evidence shapes; Amazon and Horizon access is rechecked.", + "previous": "07-account-erasure-interoperable-3", + "startedAt": "2026-09-08T20:53:50.264Z", + "recordedAt": "2026-09-08T20:53:54.769Z", + "packageVersion": "0.1.0", + "task": "# Cover the four IAPKit store boundaries\n\nMap Apple, Google, Amazon and Meta Horizon evidence into the installed protocol.\nKeep store identity distinct from the app session; Amazon and Horizon claims\nmust pass the host's authenticated store-account link. Add matching fixture\nprovider paths and execute ownership, rejection, outage and erasure cases.\nCompare the same application backend with IAPKit's actual handlers and local\nConvex storage. Mark external store calls as fixtures and do not invent a\nsubscription lifecycle or a consumable wallet from a SKU ownership result.\n\nRun npm test and the provider comparison, capture the current source, and verify\nit from an empty directory. Preserve the previous checkpoint. Keep all changes\nuncommitted for maintainer review.\n", + "sourceHashes": { + ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", + ".yarnrc.yml": "473e6def86fc03638120e0c01d0c8bbab095677256460fa4ea763e5d4697f270", + "AGENTS.md": "38764ea47552533ab5db55f7ade8c0025e2d7af9163715ce89f2d719f5475712", + "BUILD.md": "6cbf93448cf2d1925d24517a4dcac72f92f8b04538b5810cb95d0db005f7130b", + "INTEGRATE.md": "798bec31e6654120ea469c470965beca61076095c7439cfa337d9f2962d7b145", + "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", + "README.md": "31b768b8b9d031a7f95f912919ac3ea61e2e394a41d9762af00f0e6276e6ff03", + "ai-task.md": "2577865cb3e1a38dca6159d283f7c2140af15e89a5249029cd24a99153bd3e6b", + "capture.mjs": "4940347c92713f47965400fa9dca0908372ec2b3627612fccf40eb6b636a85a2", + "checkpoint-tools.mjs": "137fb2798a7e73e2db9a716d8cd3ea78fe3155ab0651ecae39f1e629634ec19c", + "checkpoint-tools.test.mjs": "1ea348330db8dab85694ac79cfec76e9d3af1729263a6a05cae61e10e41b2175", + "checkpoint.json": "0e1d49574cba3f460e51c1651b5956eddde79392a54adf9ac60f5ccaa09a0ef6", + "client-bridge.mjs": "a6760f1a9059e650fa19ef96403b16f7860390fce390262276020bd2cc7d5078", + "composition/README.md": "99e302c7365cf3969f61be2c466f31f5cf0928b2f3f0351e45907666f0459a44", + "composition/app-backend.mjs": "4b2b617634026ab2d1d936c29e246f47eb2f2d31769ff204df745785f9f6f55d", + "composition/app-backend.test.mjs": "9fbfc7fdb393b95bdf803dfb77e44a5d4311e5812e33a987b371a6b4a32a3b41", + "composition/commerce-client.mjs": "f1f1a056bc76801df25111cb12fdcde501d507a5461ef8b15bfe2f57979cd03c", + "composition/export.mjs": "ccc84dee42d59b1d2b09f16aec2b77a774765dd6fab0f341b01f1043a2e88225", + "composition/memory-provider.mjs": "edf6a7567ead756dc4a5c15d5b5b14f4d4314db31e3aa3c01c8ec543754fcc31", + "composition/purchase-flow.mjs": "ce6f98426c36eab346e900ac06ed31f2d387e542c518a48c92a67f7bae147ec8", + "composition/receiver.test.mjs": "1559036d9f0804017eeff544ed0eef7d7328111515eaeb38f6787cba815a1151", + "composition/run.mjs": "0e7aa1c8c5cab79f06d9853fa5b91f0e15f0322b4574b5f1a97f1ea119fc9b14", + "consumer.mjs": "d8e168238270309157d52ca777fea30f1d37bae38b96e88148314c894bdb1b4c", + "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", + "dashboard.html": "50a19acc0810453fa2ef55f4e5e958b4ba428764fc5a5edc10e262f9c990e532", + "erasure.mjs": "2dddb3ffd929c9d05333e88193f7cb3e5874ef5b29e68c68a1b25997d09788ea", + "export-docs.mjs": "47cf25c7ba1942fb833ac6b496c1e06b4ae06a61f6328330c6a6a4337c164f84", + "package-lock.json": "6f5333cc45203d6a27fec82c9370a8c25fef7faab591467dd2ff3c79acd6ae1c", + "package.json": "396fc93b7d50240739af2b817c172ffab8692245bd919de3b26ee713b56d455b", + "provider.mjs": "7b6300a523d87dbf9ad6e86a59990674e82a64c9917095c5b68cef3cef7a15b2", + "scenario.mjs": "b8ff9024afd67701b5f70916a61fa7d538eaec281aa4b5cc660e1c205932a32d", + "server.mjs": "b0f185a3954fbe1d7a4dd15f0dc8638afcf04e386ec730ae17a15860784d9e36", + "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", + "verify-erasure.mjs": "65feaff2e8eb0a3247ecdbc0f511c8eef5e07a4c2c63155b07409ba7cb19be88", + "verify-stores.mjs": "6f9368d1cc438b4ee7dd230a4ecbb481bedb4f58ccaa0bcc237ba9a9be4067ef", + "verify.mjs": "d3bfb64cb50672f9671162d28c82c961b7b4ef65ede147e6653d5c41204ff8b2", + "webhooks.mjs": "4838bac7a9083440998409aa1ab543999495ea6a907036bc78ba2002feb588b2" + }, + "checks": [ + "Fixture request matches the installed schema", + "Capabilities use the published response schema", + "Purchase storage starts empty", + "No profile conformance is claimed", + "Fixture evidence is accepted", + "Verification does not grant access", + "Invalid evidence produces a negative verdict", + "An upstream outage is not a negative verdict", + "Verification credentials cannot bind a user", + "The server binds Alice", + "Repeating the same binding succeeds", + "Bob cannot take Alice's purchase", + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none", + "Cancellation keeps paid access", + "Cancellation stops renewal", + "Cancellation queues one event", + "A 503 leaves a durable retry", + "Retry survives provider restart", + "Retry keeps the delivery identity", + "Redelivery has one inbox row per event", + "Access closes at expiry before a notification arrives", + "Duplicate store notification emits no extra event", + "Reopening both databases preserves state", + "Receiver deduplication survives restart", + "All four events reach the receiver", + "The final status is inactive", + "Signature vector: single-key", + "Receiver accepts vector: single-key", + "Signature vector: retry-after-backoff", + "Receiver accepts vector: retry-after-backoff", + "Signature vector: raw-utf8-body", + "Receiver accepts vector: raw-utf8-body", + "Signature vector: during-rotation", + "Receiver accepts vector: during-rotation", + "Signature vector: minimal-event-omits-extensions", + "Receiver accepts vector: minimal-event-omits-extensions", + "Receiver rejects: tampered-body", + "Receiver rejects: wrong-secret", + "Receiver rejects: timestamp-outside-tolerance", + "Receiver rejects: timestamp-not-in-signed-material", + "Receiver rejects: retry-reuses-first-signature", + "Receiver rejects: garbage-appended-to-valid-signature", + "Active: before expiry", + "Active: at expiry", + "Active: no deadline", + "InGracePeriod: before expiry", + "InGracePeriod: at expiry", + "InGracePeriod: no deadline", + "InBillingRetry: before expiry", + "InBillingRetry: at expiry", + "InBillingRetry: no deadline", + "Paused: before expiry", + "Paused: at expiry", + "Paused: no deadline", + "Expired: before expiry", + "Expired: at expiry", + "Expired: no deadline", + "Revoked: before expiry", + "Revoked: at expiry", + "Revoked: no deadline", + "Refunded: before expiry", + "Refunded: at expiry", + "Refunded: no deadline", + "Unknown: before expiry", + "Unknown: at expiry", + "Unknown: no deadline", + "FutureState: before expiry", + "FutureState: at expiry", + "FutureState: no deadline", + "Missing credentials are refused", + "Verification role cannot enumerate users", + "Malformed input is refused", + "A real store is not falsely accepted", + "Erasure rejects verification credentials", + "Cancellation after expiry is ignored", + "Expired fixture evidence has an expired verdict", + "Tampered HTTP body has no inbox effect", + "Changed UTF-8 bytes cannot reuse a signature", + "Inserted UTF-8 BOM cannot reuse a signature", + "Authenticated malformed UTF-8 is rejected before storage", + "Authentic Unicode bytes are accepted and stored", + "Authentic BOM bytes are verified before decoding", + "Receiver still has exactly four events", + "Cross-origin demo mutations are refused", + "Overlapping HTTP ownership claims have one winner", + "A cancellation older than the active row is ignored", + "Ignoring an old cancellation preserves renewal", + "Conflicting expiry is not consumed", + "Outbox failure rolls back subscription state", + "Failed transaction leaves the observation retryable", + "Exhausted retries enter dead-letter", + "Grant failure rejects binding", + "Grant failure rolls back ownership", + "An expired purchase can be bound", + "Binding expired evidence emits no grant", + "Delayed binding retains the store occurrence", + "Delayed binding records its actual processing time", + "Late expiry revokes a persisted grant exactly once", + "Repeated expiry emits no second revocation", + "Equal-time expiry retains its lifecycle transition", + "Equal-time observations revoke once and preserve both transitions", + "Health accepts the proxy public Host header", + "subscription.started: authenticated and saved", + "subscription.started: redelivery deduplicated", + "subscription.started: tampering rejected", + "entitlement.granted: authenticated and saved", + "entitlement.granted: redelivery deduplicated", + "entitlement.granted: tampering rejected", + "subscription.renewed: authenticated and saved", + "subscription.renewed: redelivery deduplicated", + "subscription.renewed: tampering rejected", + "subscription.canceled: authenticated and saved", + "subscription.canceled: redelivery deduplicated", + "subscription.canceled: tampering rejected", + "subscription.expired: authenticated and saved", + "subscription.expired: redelivery deduplicated", + "subscription.expired: tampering rejected", + "entitlement.revoked: authenticated and saved", + "entitlement.revoked: redelivery deduplicated", + "entitlement.revoked: tampering rejected", + "subscription.refunded: authenticated and saved", + "subscription.refunded: redelivery deduplicated", + "subscription.refunded: tampering rejected", + "One inbox record per event", + "Inbox survives reopening SQLite", + "apple: maps evidence without forwarding client identity", + "apple: matches the installed verification input schema", + "google: maps evidence without forwarding client identity", + "google: matches the installed verification input schema", + "amazon: uses server-selected store identity, distinct from the app user", + "amazon: matches the installed verification input schema", + "horizon: uses server-selected store identity, distinct from the app user", + "horizon: matches the installed verification input schema", + "Rejects missing purchase", + "Rejects unknown store", + "Rejects Amazon requires its authenticated store user", + "Rejects Horizon requires its authenticated store user", + "Rejects missing evidence", + "Rejects blank evidence", + "Rejects non-string evidence", + "Rejects oversized evidence", + "apple: unverified evidence cannot bind", + "apple: matching evidence verifies", + "apple: verification alone gives no access", + "apple: verification credentials cannot bind", + "apple: binding and retry keep one owner", + "apple: binding and retry keep one owner", + "apple: another account cannot claim the purchase", + "apple: owned product is accessible", + "apple: erasure completes", + "apple: erasure removes access", + "apple: erased evidence cannot be claimed", + "google: unverified evidence cannot bind", + "google: matching evidence verifies", + "google: verification alone gives no access", + "google: verification credentials cannot bind", + "google: binding and retry keep one owner", + "google: binding and retry keep one owner", + "google: another account cannot claim the purchase", + "google: owned product is accessible", + "google: erasure completes", + "google: erasure removes access", + "google: erased evidence cannot be claimed", + "amazon: unverified evidence cannot bind", + "amazon: matching evidence verifies", + "amazon: verification alone gives no access", + "amazon: verification credentials cannot bind", + "amazon: binding and retry keep one owner", + "amazon: binding and retry keep one owner", + "amazon: another account cannot claim the purchase", + "amazon: owned product is accessible", + "amazon: an outage is an error, not cached access", + "amazon: a negative recheck removes access", + "amazon: a confirmed recheck restores ownership", + "amazon: erasure completes", + "amazon: erasure removes access", + "amazon: erased evidence cannot be claimed", + "horizon: unverified evidence cannot bind", + "horizon: matching evidence verifies", + "horizon: verification alone gives no access", + "horizon: verification credentials cannot bind", + "horizon: binding and retry keep one owner", + "horizon: binding and retry keep one owner", + "horizon: another account cannot claim the purchase", + "horizon: owned product is accessible", + "horizon: an outage is an error, not cached access", + "horizon: a negative recheck removes access", + "horizon: a confirmed recheck restores ownership", + "horizon: erasure completes", + "horizon: erasure removes access", + "horizon: erased evidence cannot be claimed", + "An active purchase has a delivered event copy", + "Erasure during delivery completes", + "An in-flight event cannot resurrect receiver data", + "An in-flight acknowledgement cannot resurrect the outbox", + "Repeated erase survives restart", + "An old signed event remains discarded after restart", + "A new event ID cannot bypass erasure", + "Verification cannot bind an erased purchase", + "Stale binding cannot restore an erased account", + "Another account cannot claim erased evidence", + "Erased account is inactive before paid expiry", + "Late lifecycle processing carries no erased identity", + "Persisted protocol records contain no erased user ID", + "Unknown-user erasure is accepted", + "Verification credentials cannot erase users", + "Provider erasure completes", + "Erasure retry after restart returns the same job", + "Purchase has no account identity", + "App event copies are erased", + "Erased account has no access", + "A stale binding retry cannot restore identity" + ], + "history": [ + { + "step": 1, + "title": "Start with the contract", + "built": "HTTP routes + schema validation + SQLite", + "result": "A running server, an empty purchase table, and no access.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "providerCapabilities", + "httpStatus": 200, + "body": { + "specVersion": "1.0", + "implementation": { + "name": "Commerce Protocol Example — fictional fixture store" + }, + "eventTypes": [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked" + ], + "stores": { + "fixture": { + "initialValidation": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "serverNotifications": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "subscriptions": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "renewalEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "refundEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "expiration": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "reconciliation": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "entitlements": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "revenueAmount": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + } + } + } + } + }, + { + "operation": "providerCapabilities", + "httpStatus": 200, + "body": { + "specVersion": "1.0", + "implementation": { + "name": "Commerce Protocol Example — fictional fixture store" + }, + "eventTypes": [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked" + ], + "stores": { + "fixture": { + "initialValidation": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "serverNotifications": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "subscriptions": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "renewalEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "refundEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "expiration": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "reconciliation": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "entitlements": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "revenueAmount": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + } + } + } + } + } + ], + "checks": [ + "Fixture request matches the installed schema", + "Capabilities use the published response schema", + "Purchase storage starts empty", + "No profile conformance is claimed" + ], + "totalChecks": 4 + }, + { + "step": 2, + "title": "Verify a purchase", + "built": "Fixture store adapter + purchase persistence", + "result": "Valid evidence is saved. Alice still has no access.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [ + { + "userId": null, + "productId": "premium.monthly", + "state": "Active", + "willRenew": 1 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "verifyPurchase", + "httpStatus": 200, + "body": { + "store": "fixture", + "isValid": true, + "state": "ENTITLED", + "productId": "premium.monthly", + "environment": "local-fixture" + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "verifyPurchase", + "httpStatus": 200, + "body": { + "store": "fixture", + "isValid": false, + "state": "INAUTHENTIC", + "environment": "local-fixture" + } + }, + { + "operation": "verifyPurchase", + "httpStatus": 502, + "body": { + "error": { + "code": "VERIFICATION_FAILED", + "message": "verification failed" + } + } + } + ], + "checks": [ + "Fixture evidence is accepted", + "Verification does not grant access", + "Invalid evidence produces a negative verdict", + "An upstream outage is not a negative verdict" + ], + "totalChecks": 8 + }, + { + "step": 3, + "title": "Connect it to a user", + "built": "Server authorization + atomic binding + entitlement reads", + "result": "Alice gets Premium. Another user cannot take the purchase.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 1 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": true + } + ] + }, + "deliveries": [ + { + "eventId": "bb9fc672-2cb4-4c34-a76c-03cf2fd87ab4", + "deliveryId": "478b9617-1bcf-4dc8-b9ad-5b47675eebdf", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + } + ], + "inboxCount": 0, + "responses": [ + { + "operation": "bindPurchase", + "httpStatus": 403, + "body": { + "error": { + "code": "FORBIDDEN", + "message": "forbidden" + } + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": true + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": true + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": false + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": true + } + ] + } + } + ], + "checks": [ + "Verification credentials cannot bind a user", + "The server binds Alice", + "Repeating the same binding succeeds", + "Bob cannot take Alice's purchase", + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none" + ], + "totalChecks": 14 + }, + { + "step": 4, + "title": "Handle cancellation", + "built": "Lifecycle processing + transactional event outbox", + "result": "Renewal stops. Alice keeps the time she already paid for.", + "simulatedTime": "2026-09-08T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + ] + }, + "deliveries": [ + { + "eventId": "bb9fc672-2cb4-4c34-a76c-03cf2fd87ab4", + "deliveryId": "478b9617-1bcf-4dc8-b9ad-5b47675eebdf", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + }, + { + "eventId": "e52328c2-d0cc-4306-b38d-036766b42924", + "deliveryId": "499adaf6-1b1a-4b99-91f2-4756b0c9bc1f", + "attempts": 0, + "status": "pending", + "eventType": "subscription.canceled" + } + ], + "inboxCount": 0, + "responses": [ + { + "operation": "subscriptionStatus", + "httpStatus": 200, + "body": { + "active": true, + "subscription": { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + } + } + ], + "checks": [ + "Cancellation keeps paid access", + "Cancellation stops renewal", + "Cancellation queues one event" + ], + "totalChecks": 17 + }, + { + "step": 5, + "title": "Deliver, retry, deduplicate", + "built": "HMAC signatures + retry worker + durable receiver inbox", + "result": "A 503 retries successfully. Redelivery creates no second inbox row.", + "simulatedTime": "2026-09-08T09:00:31.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + ] + }, + "deliveries": [ + { + "eventId": "bb9fc672-2cb4-4c34-a76c-03cf2fd87ab4", + "deliveryId": "478b9617-1bcf-4dc8-b9ad-5b47675eebdf", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "e52328c2-d0cc-4306-b38d-036766b42924", + "deliveryId": "499adaf6-1b1a-4b99-91f2-4756b0c9bc1f", + "attempts": 3, + "status": "delivered", + "eventType": "subscription.canceled" + } + ], + "inboxCount": 2, + "responses": [ + { + "operation": "webhook: receiver unavailable", + "body": [ + { + "eventId": "bb9fc672-2cb4-4c34-a76c-03cf2fd87ab4", + "deliveryId": "478b9617-1bcf-4dc8-b9ad-5b47675eebdf", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + }, + { + "eventId": "e52328c2-d0cc-4306-b38d-036766b42924", + "deliveryId": "499adaf6-1b1a-4b99-91f2-4756b0c9bc1f", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + } + ] + }, + { + "operation": "webhook: retry after restart", + "body": [ + { + "eventId": "bb9fc672-2cb4-4c34-a76c-03cf2fd87ab4", + "deliveryId": "478b9617-1bcf-4dc8-b9ad-5b47675eebdf", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + }, + { + "eventId": "e52328c2-d0cc-4306-b38d-036766b42924", + "deliveryId": "499adaf6-1b1a-4b99-91f2-4756b0c9bc1f", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + } + ] + }, + { + "operation": "webhook: lost-ack redelivery", + "body": [ + { + "eventId": "bb9fc672-2cb4-4c34-a76c-03cf2fd87ab4", + "deliveryId": "478b9617-1bcf-4dc8-b9ad-5b47675eebdf", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + }, + { + "eventId": "e52328c2-d0cc-4306-b38d-036766b42924", + "deliveryId": "499adaf6-1b1a-4b99-91f2-4756b0c9bc1f", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + } + ] + } + ], + "checks": [ + "A 503 leaves a durable retry", + "Retry survives provider restart", + "Retry keeps the delivery identity", + "Redelivery has one inbox row per event" + ], + "totalChecks": 21 + }, + { + "step": 6, + "title": "Expire access and restart", + "built": "Expiry-aware reads + recovery from SQLite", + "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", + "simulatedTime": "2026-10-07T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Expired", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [ + { + "eventId": "bb9fc672-2cb4-4c34-a76c-03cf2fd87ab4", + "deliveryId": "478b9617-1bcf-4dc8-b9ad-5b47675eebdf", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "e52328c2-d0cc-4306-b38d-036766b42924", + "deliveryId": "499adaf6-1b1a-4b99-91f2-4756b0c9bc1f", + "attempts": 3, + "status": "delivered", + "eventType": "subscription.canceled" + }, + { + "eventId": "774ee355-2202-4db8-9e5b-43812888d5dd", + "deliveryId": "7b790831-15d5-4738-b749-7206da1108f2", + "attempts": 1, + "status": "delivered", + "eventType": "subscription.expired" + }, + { + "eventId": "faf0b093-6e2d-4155-8216-d6d8d5fb4dc3", + "deliveryId": "40b2cd78-3ca3-43a0-bf43-c8039e8ffbe1", + "attempts": 1, + "status": "delivered", + "eventType": "entitlement.revoked" + } + ], + "inboxCount": 4, + "responses": [ + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "webhook: expiry + revocation", + "body": [ + { + "eventId": "774ee355-2202-4db8-9e5b-43812888d5dd", + "deliveryId": "7b790831-15d5-4738-b749-7206da1108f2", + "httpStatus": 200, + "attempt": 1, + "status": "delivered" + }, + { + "eventId": "faf0b093-6e2d-4155-8216-d6d8d5fb4dc3", + "deliveryId": "40b2cd78-3ca3-43a0-bf43-c8039e8ffbe1", + "httpStatus": 200, + "attempt": 1, + "status": "delivered" + } + ] + }, + { + "operation": "subscriptionStatus", + "httpStatus": 200, + "body": { + "active": false, + "subscription": { + "productId": "premium.monthly", + "state": "Expired", + "active": false, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + } + } + ], + "checks": [ + "Access closes at expiry before a notification arrives", + "Duplicate store notification emits no extra event", + "Reopening both databases preserves state", + "Receiver deduplication survives restart", + "All four events reach the receiver", + "The final status is inactive" + ], + "totalChecks": 27 + }, + { + "step": 7, + "title": "Delete the account", + "built": "Idempotent erasure + receiver cleanup + durable deletion guard", + "result": "Alice is removed from purchases and event copies. Late deliveries cannot restore her account data.", + "simulatedTime": "2026-10-07T09:00:00.000Z", + "purchases": [ + { + "userId": null, + "productId": "premium.monthly", + "state": "Expired", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "eraseUser", + "httpStatus": 403, + "body": { + "error": { + "code": "FORBIDDEN", + "message": "forbidden" + } + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "8bbadb26-cc1f-43b1-90c0-64840a322b44", + "status": "completed" + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "8bbadb26-cc1f-43b1-90c0-64840a322b44", + "status": "completed" + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": false + } + } + ], + "checks": [ + "Verification credentials cannot erase users", + "Provider erasure completes", + "Erasure retry after restart returns the same job", + "Purchase has no account identity", + "App event copies are erased", + "Erased account has no access", + "A stale binding retry cannot restore identity" + ], + "totalChecks": 34 + } + ], + "screenshot": "screen.png", + "scope": "Incremental implementation of the installed Commerce Protocol contract. HTTP, SQLite and signatures execute locally; store and clock are fixtures. No full profile claim." +} diff --git a/docs/build/07-account-erasure-interoperable-4/screen.png b/docs/build/07-account-erasure-interoperable-4/screen.png new file mode 100644 index 0000000..982e657 Binary files /dev/null and b/docs/build/07-account-erasure-interoperable-4/screen.png differ diff --git a/docs/build/07-account-erasure-interoperable-4/source.tar.gz b/docs/build/07-account-erasure-interoperable-4/source.tar.gz new file mode 100644 index 0000000..854c426 Binary files /dev/null and b/docs/build/07-account-erasure-interoperable-4/source.tar.gz differ diff --git a/docs/build/07-account-erasure-interoperable-5/attempt-1.txt b/docs/build/07-account-erasure-interoperable-5/attempt-1.txt new file mode 100644 index 0000000..bf14549 --- /dev/null +++ b/docs/build/07-account-erasure-interoperable-5/attempt-1.txt @@ -0,0 +1,20 @@ +{ + "startedAt": "2026-09-08T21:03:40.777Z", + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 648ms\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && bun test composition/app-backend.test.mjs composition/receiver.test.mjs\n\nCommerce Protocol Example: 213 checks passed. No store or production service contacted.\nbun test v1.3.13 (bf2e2cec)\n\ncomposition/app-backend.test.mjs:\n(pass) account deletion waits for in-flight fulfillment and retries provider erasure after app restart [67.59ms]\n\ncomposition/receiver.test.mjs:\n(pass) the original SQLite and memory composition remains compatible with the receiver [39.47ms]\n(pass) one inbox scopes event IDs to authenticated emitters, survives upgrade, and discards erased users [3.69ms]\n\n 3 pass\n 0 fail\n 20 expect() calls\nRan 3 tests across 2 files. [183.00ms]\n" + }, + { + "command": "npm run test:tooling", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [14.63ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [67.02ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.53ms]\n(pass) unfinished captures do not hide completed records [0.95ms]\n(pass) checkpoint replay follows predecessors instead of directory name order [1.30ms]\n\n 5 pass\n 0 fail\nRan 5 tests across 1 file. [101.00ms]\n" + } + ] +} diff --git a/docs/build/07-account-erasure-interoperable-5/changes.patch b/docs/build/07-account-erasure-interoperable-5/changes.patch new file mode 100644 index 0000000..feef45c --- /dev/null +++ b/docs/build/07-account-erasure-interoperable-5/changes.patch @@ -0,0 +1,29 @@ +diff --git a/INTEGRATE.md b/INTEGRATE.md +index 7cfb8c3..122ee9f 100644 +--- a/INTEGRATE.md ++++ b/INTEGRATE.md +@@ -91,7 +91,8 @@ implement that callback by copying a user ID from the request body. + + ## Select the store before implementing + +-The same six protocol operations carry these four evidence shapes: ++Follow the six-step purchase flow with your chosen store. Verification and ++binding use these evidence shapes: + + | Store | Purchase evidence | IAPKit access path | + | --- | --- | --- | +diff --git a/checkpoint.json b/checkpoint.json +index 9aad24e..c2753c1 100644 +--- a/checkpoint.json ++++ b/checkpoint.json +@@ -1,8 +1,8 @@ + { + "step": 7, +- "id": "07-account-erasure-interoperable-4", ++ "id": "07-account-erasure-interoperable-5", + "title": "Delete the account", + "built": "Apple, Google, Amazon and Horizon evidence, ownership checks and account erasure", + "result": "The same account flow covers four store evidence shapes; Amazon and Horizon access is rechecked.", +- "previous": "07-account-erasure-interoperable-3" ++ "previous": "07-account-erasure-interoperable-4" + } diff --git a/docs/build/07-account-erasure-interoperable-5/mobile.png b/docs/build/07-account-erasure-interoperable-5/mobile.png new file mode 100644 index 0000000..c5c65fa Binary files /dev/null and b/docs/build/07-account-erasure-interoperable-5/mobile.png differ diff --git a/docs/build/07-account-erasure-interoperable-5/run.json b/docs/build/07-account-erasure-interoperable-5/run.json new file mode 100644 index 0000000..e718fd2 --- /dev/null +++ b/docs/build/07-account-erasure-interoperable-5/run.json @@ -0,0 +1,966 @@ +{ + "step": 7, + "id": "07-account-erasure-interoperable-5", + "title": "Delete the account", + "built": "Apple, Google, Amazon and Horizon evidence, ownership checks and account erasure", + "result": "The same account flow covers four store evidence shapes; Amazon and Horizon access is rechecked.", + "previous": "07-account-erasure-interoperable-4", + "startedAt": "2026-09-08T21:03:40.777Z", + "recordedAt": "2026-09-08T21:03:47.652Z", + "packageVersion": "0.1.0", + "task": "# Cover the four IAPKit store boundaries\n\nMap Apple, Google, Amazon and Meta Horizon evidence into the installed protocol.\nKeep store identity distinct from the app session; Amazon and Horizon claims\nmust pass the host's authenticated store-account link. Add matching fixture\nprovider paths and execute ownership, rejection, outage and erasure cases.\nCompare the same application backend with IAPKit's actual handlers and local\nConvex storage. Mark external store calls as fixtures and do not invent a\nsubscription lifecycle or a consumable wallet from a SKU ownership result.\n\nRun npm test and the provider comparison, capture the current source, and verify\nit from an empty directory. Preserve the previous checkpoint. Keep all changes\nuncommitted for maintainer review.\n", + "sourceHashes": { + ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", + ".yarnrc.yml": "473e6def86fc03638120e0c01d0c8bbab095677256460fa4ea763e5d4697f270", + "AGENTS.md": "38764ea47552533ab5db55f7ade8c0025e2d7af9163715ce89f2d719f5475712", + "BUILD.md": "6cbf93448cf2d1925d24517a4dcac72f92f8b04538b5810cb95d0db005f7130b", + "INTEGRATE.md": "ed3851d6432deedb85d2d0cc3edcca8fe52f58c79c99b68a9aa54616b010949b", + "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", + "README.md": "31b768b8b9d031a7f95f912919ac3ea61e2e394a41d9762af00f0e6276e6ff03", + "ai-task.md": "2577865cb3e1a38dca6159d283f7c2140af15e89a5249029cd24a99153bd3e6b", + "capture.mjs": "4940347c92713f47965400fa9dca0908372ec2b3627612fccf40eb6b636a85a2", + "checkpoint-tools.mjs": "137fb2798a7e73e2db9a716d8cd3ea78fe3155ab0651ecae39f1e629634ec19c", + "checkpoint-tools.test.mjs": "1ea348330db8dab85694ac79cfec76e9d3af1729263a6a05cae61e10e41b2175", + "checkpoint.json": "53069f15a87622e731a2ac54bde029d91be3f18d4e5f56c01c8c7c7a910c7ee4", + "client-bridge.mjs": "a6760f1a9059e650fa19ef96403b16f7860390fce390262276020bd2cc7d5078", + "composition/README.md": "99e302c7365cf3969f61be2c466f31f5cf0928b2f3f0351e45907666f0459a44", + "composition/app-backend.mjs": "4b2b617634026ab2d1d936c29e246f47eb2f2d31769ff204df745785f9f6f55d", + "composition/app-backend.test.mjs": "9fbfc7fdb393b95bdf803dfb77e44a5d4311e5812e33a987b371a6b4a32a3b41", + "composition/commerce-client.mjs": "f1f1a056bc76801df25111cb12fdcde501d507a5461ef8b15bfe2f57979cd03c", + "composition/export.mjs": "ccc84dee42d59b1d2b09f16aec2b77a774765dd6fab0f341b01f1043a2e88225", + "composition/memory-provider.mjs": "edf6a7567ead756dc4a5c15d5b5b14f4d4314db31e3aa3c01c8ec543754fcc31", + "composition/purchase-flow.mjs": "ce6f98426c36eab346e900ac06ed31f2d387e542c518a48c92a67f7bae147ec8", + "composition/receiver.test.mjs": "1559036d9f0804017eeff544ed0eef7d7328111515eaeb38f6787cba815a1151", + "composition/run.mjs": "0e7aa1c8c5cab79f06d9853fa5b91f0e15f0322b4574b5f1a97f1ea119fc9b14", + "consumer.mjs": "d8e168238270309157d52ca777fea30f1d37bae38b96e88148314c894bdb1b4c", + "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", + "dashboard.html": "50a19acc0810453fa2ef55f4e5e958b4ba428764fc5a5edc10e262f9c990e532", + "erasure.mjs": "2dddb3ffd929c9d05333e88193f7cb3e5874ef5b29e68c68a1b25997d09788ea", + "export-docs.mjs": "47cf25c7ba1942fb833ac6b496c1e06b4ae06a61f6328330c6a6a4337c164f84", + "package-lock.json": "6f5333cc45203d6a27fec82c9370a8c25fef7faab591467dd2ff3c79acd6ae1c", + "package.json": "396fc93b7d50240739af2b817c172ffab8692245bd919de3b26ee713b56d455b", + "provider.mjs": "7b6300a523d87dbf9ad6e86a59990674e82a64c9917095c5b68cef3cef7a15b2", + "scenario.mjs": "b8ff9024afd67701b5f70916a61fa7d538eaec281aa4b5cc660e1c205932a32d", + "server.mjs": "b0f185a3954fbe1d7a4dd15f0dc8638afcf04e386ec730ae17a15860784d9e36", + "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", + "verify-erasure.mjs": "65feaff2e8eb0a3247ecdbc0f511c8eef5e07a4c2c63155b07409ba7cb19be88", + "verify-stores.mjs": "6f9368d1cc438b4ee7dd230a4ecbb481bedb4f58ccaa0bcc237ba9a9be4067ef", + "verify.mjs": "d3bfb64cb50672f9671162d28c82c961b7b4ef65ede147e6653d5c41204ff8b2", + "webhooks.mjs": "4838bac7a9083440998409aa1ab543999495ea6a907036bc78ba2002feb588b2" + }, + "checks": [ + "Fixture request matches the installed schema", + "Capabilities use the published response schema", + "Purchase storage starts empty", + "No profile conformance is claimed", + "Fixture evidence is accepted", + "Verification does not grant access", + "Invalid evidence produces a negative verdict", + "An upstream outage is not a negative verdict", + "Verification credentials cannot bind a user", + "The server binds Alice", + "Repeating the same binding succeeds", + "Bob cannot take Alice's purchase", + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none", + "Cancellation keeps paid access", + "Cancellation stops renewal", + "Cancellation queues one event", + "A 503 leaves a durable retry", + "Retry survives provider restart", + "Retry keeps the delivery identity", + "Redelivery has one inbox row per event", + "Access closes at expiry before a notification arrives", + "Duplicate store notification emits no extra event", + "Reopening both databases preserves state", + "Receiver deduplication survives restart", + "All four events reach the receiver", + "The final status is inactive", + "Signature vector: single-key", + "Receiver accepts vector: single-key", + "Signature vector: retry-after-backoff", + "Receiver accepts vector: retry-after-backoff", + "Signature vector: raw-utf8-body", + "Receiver accepts vector: raw-utf8-body", + "Signature vector: during-rotation", + "Receiver accepts vector: during-rotation", + "Signature vector: minimal-event-omits-extensions", + "Receiver accepts vector: minimal-event-omits-extensions", + "Receiver rejects: tampered-body", + "Receiver rejects: wrong-secret", + "Receiver rejects: timestamp-outside-tolerance", + "Receiver rejects: timestamp-not-in-signed-material", + "Receiver rejects: retry-reuses-first-signature", + "Receiver rejects: garbage-appended-to-valid-signature", + "Active: before expiry", + "Active: at expiry", + "Active: no deadline", + "InGracePeriod: before expiry", + "InGracePeriod: at expiry", + "InGracePeriod: no deadline", + "InBillingRetry: before expiry", + "InBillingRetry: at expiry", + "InBillingRetry: no deadline", + "Paused: before expiry", + "Paused: at expiry", + "Paused: no deadline", + "Expired: before expiry", + "Expired: at expiry", + "Expired: no deadline", + "Revoked: before expiry", + "Revoked: at expiry", + "Revoked: no deadline", + "Refunded: before expiry", + "Refunded: at expiry", + "Refunded: no deadline", + "Unknown: before expiry", + "Unknown: at expiry", + "Unknown: no deadline", + "FutureState: before expiry", + "FutureState: at expiry", + "FutureState: no deadline", + "Missing credentials are refused", + "Verification role cannot enumerate users", + "Malformed input is refused", + "A real store is not falsely accepted", + "Erasure rejects verification credentials", + "Cancellation after expiry is ignored", + "Expired fixture evidence has an expired verdict", + "Tampered HTTP body has no inbox effect", + "Changed UTF-8 bytes cannot reuse a signature", + "Inserted UTF-8 BOM cannot reuse a signature", + "Authenticated malformed UTF-8 is rejected before storage", + "Authentic Unicode bytes are accepted and stored", + "Authentic BOM bytes are verified before decoding", + "Receiver still has exactly four events", + "Cross-origin demo mutations are refused", + "Overlapping HTTP ownership claims have one winner", + "A cancellation older than the active row is ignored", + "Ignoring an old cancellation preserves renewal", + "Conflicting expiry is not consumed", + "Outbox failure rolls back subscription state", + "Failed transaction leaves the observation retryable", + "Exhausted retries enter dead-letter", + "Grant failure rejects binding", + "Grant failure rolls back ownership", + "An expired purchase can be bound", + "Binding expired evidence emits no grant", + "Delayed binding retains the store occurrence", + "Delayed binding records its actual processing time", + "Late expiry revokes a persisted grant exactly once", + "Repeated expiry emits no second revocation", + "Equal-time expiry retains its lifecycle transition", + "Equal-time observations revoke once and preserve both transitions", + "Health accepts the proxy public Host header", + "subscription.started: authenticated and saved", + "subscription.started: redelivery deduplicated", + "subscription.started: tampering rejected", + "entitlement.granted: authenticated and saved", + "entitlement.granted: redelivery deduplicated", + "entitlement.granted: tampering rejected", + "subscription.renewed: authenticated and saved", + "subscription.renewed: redelivery deduplicated", + "subscription.renewed: tampering rejected", + "subscription.canceled: authenticated and saved", + "subscription.canceled: redelivery deduplicated", + "subscription.canceled: tampering rejected", + "subscription.expired: authenticated and saved", + "subscription.expired: redelivery deduplicated", + "subscription.expired: tampering rejected", + "entitlement.revoked: authenticated and saved", + "entitlement.revoked: redelivery deduplicated", + "entitlement.revoked: tampering rejected", + "subscription.refunded: authenticated and saved", + "subscription.refunded: redelivery deduplicated", + "subscription.refunded: tampering rejected", + "One inbox record per event", + "Inbox survives reopening SQLite", + "apple: maps evidence without forwarding client identity", + "apple: matches the installed verification input schema", + "google: maps evidence without forwarding client identity", + "google: matches the installed verification input schema", + "amazon: uses server-selected store identity, distinct from the app user", + "amazon: matches the installed verification input schema", + "horizon: uses server-selected store identity, distinct from the app user", + "horizon: matches the installed verification input schema", + "Rejects missing purchase", + "Rejects unknown store", + "Rejects Amazon requires its authenticated store user", + "Rejects Horizon requires its authenticated store user", + "Rejects missing evidence", + "Rejects blank evidence", + "Rejects non-string evidence", + "Rejects oversized evidence", + "apple: unverified evidence cannot bind", + "apple: matching evidence verifies", + "apple: verification alone gives no access", + "apple: verification credentials cannot bind", + "apple: binding and retry keep one owner", + "apple: binding and retry keep one owner", + "apple: another account cannot claim the purchase", + "apple: owned product is accessible", + "apple: erasure completes", + "apple: erasure removes access", + "apple: erased evidence cannot be claimed", + "google: unverified evidence cannot bind", + "google: matching evidence verifies", + "google: verification alone gives no access", + "google: verification credentials cannot bind", + "google: binding and retry keep one owner", + "google: binding and retry keep one owner", + "google: another account cannot claim the purchase", + "google: owned product is accessible", + "google: erasure completes", + "google: erasure removes access", + "google: erased evidence cannot be claimed", + "amazon: unverified evidence cannot bind", + "amazon: matching evidence verifies", + "amazon: verification alone gives no access", + "amazon: verification credentials cannot bind", + "amazon: binding and retry keep one owner", + "amazon: binding and retry keep one owner", + "amazon: another account cannot claim the purchase", + "amazon: owned product is accessible", + "amazon: an outage is an error, not cached access", + "amazon: a negative recheck removes access", + "amazon: a confirmed recheck restores ownership", + "amazon: erasure completes", + "amazon: erasure removes access", + "amazon: erased evidence cannot be claimed", + "horizon: unverified evidence cannot bind", + "horizon: matching evidence verifies", + "horizon: verification alone gives no access", + "horizon: verification credentials cannot bind", + "horizon: binding and retry keep one owner", + "horizon: binding and retry keep one owner", + "horizon: another account cannot claim the purchase", + "horizon: owned product is accessible", + "horizon: an outage is an error, not cached access", + "horizon: a negative recheck removes access", + "horizon: a confirmed recheck restores ownership", + "horizon: erasure completes", + "horizon: erasure removes access", + "horizon: erased evidence cannot be claimed", + "An active purchase has a delivered event copy", + "Erasure during delivery completes", + "An in-flight event cannot resurrect receiver data", + "An in-flight acknowledgement cannot resurrect the outbox", + "Repeated erase survives restart", + "An old signed event remains discarded after restart", + "A new event ID cannot bypass erasure", + "Verification cannot bind an erased purchase", + "Stale binding cannot restore an erased account", + "Another account cannot claim erased evidence", + "Erased account is inactive before paid expiry", + "Late lifecycle processing carries no erased identity", + "Persisted protocol records contain no erased user ID", + "Unknown-user erasure is accepted", + "Verification credentials cannot erase users", + "Provider erasure completes", + "Erasure retry after restart returns the same job", + "Purchase has no account identity", + "App event copies are erased", + "Erased account has no access", + "A stale binding retry cannot restore identity" + ], + "history": [ + { + "step": 1, + "title": "Start with the contract", + "built": "HTTP routes + schema validation + SQLite", + "result": "A running server, an empty purchase table, and no access.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "providerCapabilities", + "httpStatus": 200, + "body": { + "specVersion": "1.0", + "implementation": { + "name": "Commerce Protocol Example — fictional fixture store" + }, + "eventTypes": [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked" + ], + "stores": { + "fixture": { + "initialValidation": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "serverNotifications": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "subscriptions": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "renewalEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "refundEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "expiration": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "reconciliation": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "entitlements": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "revenueAmount": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + } + } + } + } + }, + { + "operation": "providerCapabilities", + "httpStatus": 200, + "body": { + "specVersion": "1.0", + "implementation": { + "name": "Commerce Protocol Example — fictional fixture store" + }, + "eventTypes": [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked" + ], + "stores": { + "fixture": { + "initialValidation": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "serverNotifications": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "subscriptions": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "renewalEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "refundEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "expiration": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "reconciliation": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "entitlements": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "revenueAmount": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + } + } + } + } + } + ], + "checks": [ + "Fixture request matches the installed schema", + "Capabilities use the published response schema", + "Purchase storage starts empty", + "No profile conformance is claimed" + ], + "totalChecks": 4 + }, + { + "step": 2, + "title": "Verify a purchase", + "built": "Fixture store adapter + purchase persistence", + "result": "Valid evidence is saved. Alice still has no access.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [ + { + "userId": null, + "productId": "premium.monthly", + "state": "Active", + "willRenew": 1 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "verifyPurchase", + "httpStatus": 200, + "body": { + "store": "fixture", + "isValid": true, + "state": "ENTITLED", + "productId": "premium.monthly", + "environment": "local-fixture" + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "verifyPurchase", + "httpStatus": 200, + "body": { + "store": "fixture", + "isValid": false, + "state": "INAUTHENTIC", + "environment": "local-fixture" + } + }, + { + "operation": "verifyPurchase", + "httpStatus": 502, + "body": { + "error": { + "code": "VERIFICATION_FAILED", + "message": "verification failed" + } + } + } + ], + "checks": [ + "Fixture evidence is accepted", + "Verification does not grant access", + "Invalid evidence produces a negative verdict", + "An upstream outage is not a negative verdict" + ], + "totalChecks": 8 + }, + { + "step": 3, + "title": "Connect it to a user", + "built": "Server authorization + atomic binding + entitlement reads", + "result": "Alice gets Premium. Another user cannot take the purchase.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 1 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": true + } + ] + }, + "deliveries": [ + { + "eventId": "64378226-3953-4f1b-abae-d86d2221d4b4", + "deliveryId": "38018978-fa63-498c-92a1-de617190b1ae", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + } + ], + "inboxCount": 0, + "responses": [ + { + "operation": "bindPurchase", + "httpStatus": 403, + "body": { + "error": { + "code": "FORBIDDEN", + "message": "forbidden" + } + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": true + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": true + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": false + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": true + } + ] + } + } + ], + "checks": [ + "Verification credentials cannot bind a user", + "The server binds Alice", + "Repeating the same binding succeeds", + "Bob cannot take Alice's purchase", + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none" + ], + "totalChecks": 14 + }, + { + "step": 4, + "title": "Handle cancellation", + "built": "Lifecycle processing + transactional event outbox", + "result": "Renewal stops. Alice keeps the time she already paid for.", + "simulatedTime": "2026-09-08T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + ] + }, + "deliveries": [ + { + "eventId": "64378226-3953-4f1b-abae-d86d2221d4b4", + "deliveryId": "38018978-fa63-498c-92a1-de617190b1ae", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + }, + { + "eventId": "1f52a7ba-cbc8-404c-887b-72a29f66fae8", + "deliveryId": "c39c155c-8721-4139-9f27-4b40047b6e46", + "attempts": 0, + "status": "pending", + "eventType": "subscription.canceled" + } + ], + "inboxCount": 0, + "responses": [ + { + "operation": "subscriptionStatus", + "httpStatus": 200, + "body": { + "active": true, + "subscription": { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + } + } + ], + "checks": [ + "Cancellation keeps paid access", + "Cancellation stops renewal", + "Cancellation queues one event" + ], + "totalChecks": 17 + }, + { + "step": 5, + "title": "Deliver, retry, deduplicate", + "built": "HMAC signatures + retry worker + durable receiver inbox", + "result": "A 503 retries successfully. Redelivery creates no second inbox row.", + "simulatedTime": "2026-09-08T09:00:31.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + ] + }, + "deliveries": [ + { + "eventId": "64378226-3953-4f1b-abae-d86d2221d4b4", + "deliveryId": "38018978-fa63-498c-92a1-de617190b1ae", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "1f52a7ba-cbc8-404c-887b-72a29f66fae8", + "deliveryId": "c39c155c-8721-4139-9f27-4b40047b6e46", + "attempts": 3, + "status": "delivered", + "eventType": "subscription.canceled" + } + ], + "inboxCount": 2, + "responses": [ + { + "operation": "webhook: receiver unavailable", + "body": [ + { + "eventId": "64378226-3953-4f1b-abae-d86d2221d4b4", + "deliveryId": "38018978-fa63-498c-92a1-de617190b1ae", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + }, + { + "eventId": "1f52a7ba-cbc8-404c-887b-72a29f66fae8", + "deliveryId": "c39c155c-8721-4139-9f27-4b40047b6e46", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + } + ] + }, + { + "operation": "webhook: retry after restart", + "body": [ + { + "eventId": "64378226-3953-4f1b-abae-d86d2221d4b4", + "deliveryId": "38018978-fa63-498c-92a1-de617190b1ae", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + }, + { + "eventId": "1f52a7ba-cbc8-404c-887b-72a29f66fae8", + "deliveryId": "c39c155c-8721-4139-9f27-4b40047b6e46", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + } + ] + }, + { + "operation": "webhook: lost-ack redelivery", + "body": [ + { + "eventId": "64378226-3953-4f1b-abae-d86d2221d4b4", + "deliveryId": "38018978-fa63-498c-92a1-de617190b1ae", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + }, + { + "eventId": "1f52a7ba-cbc8-404c-887b-72a29f66fae8", + "deliveryId": "c39c155c-8721-4139-9f27-4b40047b6e46", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + } + ] + } + ], + "checks": [ + "A 503 leaves a durable retry", + "Retry survives provider restart", + "Retry keeps the delivery identity", + "Redelivery has one inbox row per event" + ], + "totalChecks": 21 + }, + { + "step": 6, + "title": "Expire access and restart", + "built": "Expiry-aware reads + recovery from SQLite", + "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", + "simulatedTime": "2026-10-07T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Expired", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [ + { + "eventId": "64378226-3953-4f1b-abae-d86d2221d4b4", + "deliveryId": "38018978-fa63-498c-92a1-de617190b1ae", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "1f52a7ba-cbc8-404c-887b-72a29f66fae8", + "deliveryId": "c39c155c-8721-4139-9f27-4b40047b6e46", + "attempts": 3, + "status": "delivered", + "eventType": "subscription.canceled" + }, + { + "eventId": "15b48409-9ee4-42cd-8ec6-cdfe36e64bc6", + "deliveryId": "49499c70-c744-4fa7-acd5-f17828052b26", + "attempts": 1, + "status": "delivered", + "eventType": "subscription.expired" + }, + { + "eventId": "21c51f70-e6fd-4eea-b81e-a54fd8c2b4d6", + "deliveryId": "18aede76-48e5-41f5-9e67-4aeebd4fb23f", + "attempts": 1, + "status": "delivered", + "eventType": "entitlement.revoked" + } + ], + "inboxCount": 4, + "responses": [ + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "webhook: expiry + revocation", + "body": [ + { + "eventId": "15b48409-9ee4-42cd-8ec6-cdfe36e64bc6", + "deliveryId": "49499c70-c744-4fa7-acd5-f17828052b26", + "httpStatus": 200, + "attempt": 1, + "status": "delivered" + }, + { + "eventId": "21c51f70-e6fd-4eea-b81e-a54fd8c2b4d6", + "deliveryId": "18aede76-48e5-41f5-9e67-4aeebd4fb23f", + "httpStatus": 200, + "attempt": 1, + "status": "delivered" + } + ] + }, + { + "operation": "subscriptionStatus", + "httpStatus": 200, + "body": { + "active": false, + "subscription": { + "productId": "premium.monthly", + "state": "Expired", + "active": false, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + } + } + ], + "checks": [ + "Access closes at expiry before a notification arrives", + "Duplicate store notification emits no extra event", + "Reopening both databases preserves state", + "Receiver deduplication survives restart", + "All four events reach the receiver", + "The final status is inactive" + ], + "totalChecks": 27 + }, + { + "step": 7, + "title": "Delete the account", + "built": "Idempotent erasure + receiver cleanup + durable deletion guard", + "result": "Alice is removed from purchases and event copies. Late deliveries cannot restore her account data.", + "simulatedTime": "2026-10-07T09:00:00.000Z", + "purchases": [ + { + "userId": null, + "productId": "premium.monthly", + "state": "Expired", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "eraseUser", + "httpStatus": 403, + "body": { + "error": { + "code": "FORBIDDEN", + "message": "forbidden" + } + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "fe502cf9-1a7d-4cb9-b100-070c4ddfc081", + "status": "completed" + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "fe502cf9-1a7d-4cb9-b100-070c4ddfc081", + "status": "completed" + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": false + } + } + ], + "checks": [ + "Verification credentials cannot erase users", + "Provider erasure completes", + "Erasure retry after restart returns the same job", + "Purchase has no account identity", + "App event copies are erased", + "Erased account has no access", + "A stale binding retry cannot restore identity" + ], + "totalChecks": 34 + } + ], + "screenshot": "screen.png", + "scope": "Incremental implementation of the installed Commerce Protocol contract. HTTP, SQLite and signatures execute locally; store and clock are fixtures. No full profile claim." +} diff --git a/docs/build/07-account-erasure-interoperable-5/screen.png b/docs/build/07-account-erasure-interoperable-5/screen.png new file mode 100644 index 0000000..982e657 Binary files /dev/null and b/docs/build/07-account-erasure-interoperable-5/screen.png differ diff --git a/docs/build/07-account-erasure-interoperable-5/source.tar.gz b/docs/build/07-account-erasure-interoperable-5/source.tar.gz new file mode 100644 index 0000000..06e43c4 Binary files /dev/null and b/docs/build/07-account-erasure-interoperable-5/source.tar.gz differ diff --git a/docs/build/07-account-erasure-interoperable-6/attempt-1.txt b/docs/build/07-account-erasure-interoperable-6/attempt-1.txt new file mode 100644 index 0000000..98fb5b7 --- /dev/null +++ b/docs/build/07-account-erasure-interoperable-6/attempt-1.txt @@ -0,0 +1,20 @@ +{ + "startedAt": "2026-09-08T22:46:40.004Z", + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 450ms\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && bun test composition/app-backend.test.mjs composition/receiver.test.mjs\n\nCommerce Protocol Example: 229 checks passed. No store or production service contacted.\nbun test v1.3.13 (bf2e2cec)\n\ncomposition/app-backend.test.mjs:\n(pass) account deletion waits for in-flight fulfillment and retries provider erasure after app restart [80.35ms]\n\ncomposition/receiver.test.mjs:\n(pass) the original SQLite and memory composition remains compatible with the receiver [47.71ms]\n(pass) one inbox scopes event IDs to authenticated emitters, survives upgrade, and discards erased users [5.11ms]\n\n 3 pass\n 0 fail\n 20 expect() calls\nRan 3 tests across 2 files. [224.00ms]\n" + }, + { + "command": "npm run test:tooling", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [13.56ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [68.17ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.65ms]\n(pass) unfinished captures do not hide completed records [0.97ms]\n(pass) checkpoint replay follows predecessors instead of directory name order [1.41ms]\n\n 5 pass\n 0 fail\nRan 5 tests across 1 file. [104.00ms]\n" + } + ] +} diff --git a/docs/build/07-account-erasure-interoperable-6/changes.patch b/docs/build/07-account-erasure-interoperable-6/changes.patch new file mode 100644 index 0000000..b1e23da --- /dev/null +++ b/docs/build/07-account-erasure-interoperable-6/changes.patch @@ -0,0 +1,100 @@ +diff --git a/checkpoint.json b/checkpoint.json +index c2753c1..9344586 100644 +--- a/checkpoint.json ++++ b/checkpoint.json +@@ -1,8 +1,8 @@ + { + "step": 7, +- "id": "07-account-erasure-interoperable-5", ++ "id": "07-account-erasure-interoperable-6", + "title": "Delete the account", + "built": "Apple, Google, Amazon and Horizon evidence, ownership checks and account erasure", + "result": "The same account flow covers four store evidence shapes; Amazon and Horizon access is rechecked.", +- "previous": "07-account-erasure-interoperable-4" ++ "previous": "07-account-erasure-interoperable-5" + } +diff --git a/provider.mjs b/provider.mjs +index 3a50693..3e23093 100644 +--- a/provider.mjs ++++ b/provider.mjs +@@ -212,12 +212,13 @@ export function createProvider(path, now, fixture = FIXTURE) { + fixture.startsAt, + ); + } ++ const expired = !fixture.pointInTime && now() >= fixture.expiresAt; + return { + store: fixture.store, +- isValid: verdict.accepted && now() < fixture.expiresAt, ++ isValid: verdict.accepted && !expired, + state: !verdict.accepted + ? "INAUTHENTIC" +- : now() >= fixture.expiresAt ++ : expired + ? "EXPIRED" + : "ENTITLED", + ...(verdict.accepted ? { productId: fixture.productId } : {}), +diff --git a/verify-stores.mjs b/verify-stores.mjs +index 30d87ff..01bc808 100644 +--- a/verify-stores.mjs ++++ b/verify-stores.mjs +@@ -7,6 +7,7 @@ export async function verifyStores() { + const checks = []; + for (const store of ["apple", "google", "amazon", "horizon"]) { + let current = true; ++ let time = Date.now(); + const input = toVerifyPurchaseInput( + { store, purchaseToken: "fictional-proof", productId: "premium.monthly" }, + { storeUserId: "fixture-store-user", amazonSandbox: true }, +@@ -22,12 +23,12 @@ export async function verifyStores() { + evidence, + userId: "alice", + productId: "premium.monthly", +- startsAt: Date.now(), +- expiresAt: Date.now() + 60000, ++ startsAt: time, ++ expiresAt: time + 60000, + pointInTime: ["amazon", "horizon"].includes(store), + currentVerdict: () => current, + }; +- const provider = createProvider(":memory:", Date.now, fixture); ++ const provider = createProvider(":memory:", () => time, fixture); + const check = (label, actual, expected) => { + assert.deepEqual(actual, expected, `${store}: ${label}`); + checks.push(`${store}: ${label}`); +@@ -130,6 +131,36 @@ export async function verifyStores() { + (await call("bindPurchase", { ...input, userId: "bob" })).result.bound, + false, + ); ++ for (const [label, offset] of [ ++ ["before", -1], ++ ["at", 0], ++ ["after", 1], ++ ]) { ++ time = fixture.expiresAt + offset; ++ const verdict = (await call("verifyPurchase", input)).result; ++ check( ++ `verification ${label} the fixture deadline respects the store's access model`, ++ [verdict.isValid, verdict.state], ++ fixture.pointInTime || offset < 0 ++ ? [true, "ENTITLED"] ++ : [false, "EXPIRED"], ++ ); ++ } ++ if (fixture.pointInTime) { ++ current = false; ++ const rejected = (await call("verifyPurchase", input)).result; ++ check( ++ "a negative ownership verdict remains rejected after the fixture deadline", ++ [rejected.isValid, rejected.state], ++ [false, "INAUTHENTIC"], ++ ); ++ current = "outage"; ++ check( ++ "an ownership verification outage remains an error after the fixture deadline", ++ (await call("verifyPurchase", input)).status, ++ 502, ++ ); ++ } + } finally { + provider.close(); + } diff --git a/docs/build/07-account-erasure-interoperable-6/mobile.png b/docs/build/07-account-erasure-interoperable-6/mobile.png new file mode 100644 index 0000000..c5c65fa Binary files /dev/null and b/docs/build/07-account-erasure-interoperable-6/mobile.png differ diff --git a/docs/build/07-account-erasure-interoperable-6/run.json b/docs/build/07-account-erasure-interoperable-6/run.json new file mode 100644 index 0000000..dd47c6a --- /dev/null +++ b/docs/build/07-account-erasure-interoperable-6/run.json @@ -0,0 +1,982 @@ +{ + "step": 7, + "id": "07-account-erasure-interoperable-6", + "title": "Delete the account", + "built": "Apple, Google, Amazon and Horizon evidence, ownership checks and account erasure", + "result": "The same account flow covers four store evidence shapes; Amazon and Horizon access is rechecked.", + "previous": "07-account-erasure-interoperable-5", + "startedAt": "2026-09-08T22:46:40.004Z", + "recordedAt": "2026-09-08T22:46:50.749Z", + "packageVersion": "0.1.0", + "task": "# Cover the four IAPKit store boundaries\n\nMap Apple, Google, Amazon and Meta Horizon evidence into the installed protocol.\nKeep store identity distinct from the app session; Amazon and Horizon claims\nmust pass the host's authenticated store-account link. Add matching fixture\nprovider paths and execute ownership, rejection, outage and erasure cases.\nCompare the same application backend with IAPKit's actual handlers and local\nConvex storage. Mark external store calls as fixtures and do not invent a\nsubscription lifecycle or a consumable wallet from a SKU ownership result.\n\nRun npm test and the provider comparison, capture the current source, and verify\nit from an empty directory. Preserve the previous checkpoint. Keep all changes\nuncommitted for maintainer review.\n", + "sourceHashes": { + ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", + ".yarnrc.yml": "473e6def86fc03638120e0c01d0c8bbab095677256460fa4ea763e5d4697f270", + "AGENTS.md": "38764ea47552533ab5db55f7ade8c0025e2d7af9163715ce89f2d719f5475712", + "BUILD.md": "6cbf93448cf2d1925d24517a4dcac72f92f8b04538b5810cb95d0db005f7130b", + "INTEGRATE.md": "ed3851d6432deedb85d2d0cc3edcca8fe52f58c79c99b68a9aa54616b010949b", + "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", + "README.md": "31b768b8b9d031a7f95f912919ac3ea61e2e394a41d9762af00f0e6276e6ff03", + "ai-task.md": "2577865cb3e1a38dca6159d283f7c2140af15e89a5249029cd24a99153bd3e6b", + "capture.mjs": "4940347c92713f47965400fa9dca0908372ec2b3627612fccf40eb6b636a85a2", + "checkpoint-tools.mjs": "137fb2798a7e73e2db9a716d8cd3ea78fe3155ab0651ecae39f1e629634ec19c", + "checkpoint-tools.test.mjs": "1ea348330db8dab85694ac79cfec76e9d3af1729263a6a05cae61e10e41b2175", + "checkpoint.json": "5db99c8e53c5e441bb0d3a9058339f679873d1ade99bedd01d8c41b2653acc48", + "client-bridge.mjs": "a6760f1a9059e650fa19ef96403b16f7860390fce390262276020bd2cc7d5078", + "composition/README.md": "99e302c7365cf3969f61be2c466f31f5cf0928b2f3f0351e45907666f0459a44", + "composition/app-backend.mjs": "4b2b617634026ab2d1d936c29e246f47eb2f2d31769ff204df745785f9f6f55d", + "composition/app-backend.test.mjs": "9fbfc7fdb393b95bdf803dfb77e44a5d4311e5812e33a987b371a6b4a32a3b41", + "composition/commerce-client.mjs": "f1f1a056bc76801df25111cb12fdcde501d507a5461ef8b15bfe2f57979cd03c", + "composition/export.mjs": "ccc84dee42d59b1d2b09f16aec2b77a774765dd6fab0f341b01f1043a2e88225", + "composition/memory-provider.mjs": "edf6a7567ead756dc4a5c15d5b5b14f4d4314db31e3aa3c01c8ec543754fcc31", + "composition/purchase-flow.mjs": "ce6f98426c36eab346e900ac06ed31f2d387e542c518a48c92a67f7bae147ec8", + "composition/receiver.test.mjs": "1559036d9f0804017eeff544ed0eef7d7328111515eaeb38f6787cba815a1151", + "composition/run.mjs": "0e7aa1c8c5cab79f06d9853fa5b91f0e15f0322b4574b5f1a97f1ea119fc9b14", + "consumer.mjs": "d8e168238270309157d52ca777fea30f1d37bae38b96e88148314c894bdb1b4c", + "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", + "dashboard.html": "50a19acc0810453fa2ef55f4e5e958b4ba428764fc5a5edc10e262f9c990e532", + "erasure.mjs": "2dddb3ffd929c9d05333e88193f7cb3e5874ef5b29e68c68a1b25997d09788ea", + "export-docs.mjs": "47cf25c7ba1942fb833ac6b496c1e06b4ae06a61f6328330c6a6a4337c164f84", + "package-lock.json": "6f5333cc45203d6a27fec82c9370a8c25fef7faab591467dd2ff3c79acd6ae1c", + "package.json": "396fc93b7d50240739af2b817c172ffab8692245bd919de3b26ee713b56d455b", + "provider.mjs": "682b2c24797d5007373c1951641b2f6f156c933e654b2ad1865219be64b15d51", + "scenario.mjs": "b8ff9024afd67701b5f70916a61fa7d538eaec281aa4b5cc660e1c205932a32d", + "server.mjs": "b0f185a3954fbe1d7a4dd15f0dc8638afcf04e386ec730ae17a15860784d9e36", + "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", + "verify-erasure.mjs": "65feaff2e8eb0a3247ecdbc0f511c8eef5e07a4c2c63155b07409ba7cb19be88", + "verify-stores.mjs": "6a9ea6071aebddac158435b8577f4b31914d902191f6cc1476683ea6d9bac3dd", + "verify.mjs": "d3bfb64cb50672f9671162d28c82c961b7b4ef65ede147e6653d5c41204ff8b2", + "webhooks.mjs": "4838bac7a9083440998409aa1ab543999495ea6a907036bc78ba2002feb588b2" + }, + "checks": [ + "Fixture request matches the installed schema", + "Capabilities use the published response schema", + "Purchase storage starts empty", + "No profile conformance is claimed", + "Fixture evidence is accepted", + "Verification does not grant access", + "Invalid evidence produces a negative verdict", + "An upstream outage is not a negative verdict", + "Verification credentials cannot bind a user", + "The server binds Alice", + "Repeating the same binding succeeds", + "Bob cannot take Alice's purchase", + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none", + "Cancellation keeps paid access", + "Cancellation stops renewal", + "Cancellation queues one event", + "A 503 leaves a durable retry", + "Retry survives provider restart", + "Retry keeps the delivery identity", + "Redelivery has one inbox row per event", + "Access closes at expiry before a notification arrives", + "Duplicate store notification emits no extra event", + "Reopening both databases preserves state", + "Receiver deduplication survives restart", + "All four events reach the receiver", + "The final status is inactive", + "Signature vector: single-key", + "Receiver accepts vector: single-key", + "Signature vector: retry-after-backoff", + "Receiver accepts vector: retry-after-backoff", + "Signature vector: raw-utf8-body", + "Receiver accepts vector: raw-utf8-body", + "Signature vector: during-rotation", + "Receiver accepts vector: during-rotation", + "Signature vector: minimal-event-omits-extensions", + "Receiver accepts vector: minimal-event-omits-extensions", + "Receiver rejects: tampered-body", + "Receiver rejects: wrong-secret", + "Receiver rejects: timestamp-outside-tolerance", + "Receiver rejects: timestamp-not-in-signed-material", + "Receiver rejects: retry-reuses-first-signature", + "Receiver rejects: garbage-appended-to-valid-signature", + "Active: before expiry", + "Active: at expiry", + "Active: no deadline", + "InGracePeriod: before expiry", + "InGracePeriod: at expiry", + "InGracePeriod: no deadline", + "InBillingRetry: before expiry", + "InBillingRetry: at expiry", + "InBillingRetry: no deadline", + "Paused: before expiry", + "Paused: at expiry", + "Paused: no deadline", + "Expired: before expiry", + "Expired: at expiry", + "Expired: no deadline", + "Revoked: before expiry", + "Revoked: at expiry", + "Revoked: no deadline", + "Refunded: before expiry", + "Refunded: at expiry", + "Refunded: no deadline", + "Unknown: before expiry", + "Unknown: at expiry", + "Unknown: no deadline", + "FutureState: before expiry", + "FutureState: at expiry", + "FutureState: no deadline", + "Missing credentials are refused", + "Verification role cannot enumerate users", + "Malformed input is refused", + "A real store is not falsely accepted", + "Erasure rejects verification credentials", + "Cancellation after expiry is ignored", + "Expired fixture evidence has an expired verdict", + "Tampered HTTP body has no inbox effect", + "Changed UTF-8 bytes cannot reuse a signature", + "Inserted UTF-8 BOM cannot reuse a signature", + "Authenticated malformed UTF-8 is rejected before storage", + "Authentic Unicode bytes are accepted and stored", + "Authentic BOM bytes are verified before decoding", + "Receiver still has exactly four events", + "Cross-origin demo mutations are refused", + "Overlapping HTTP ownership claims have one winner", + "A cancellation older than the active row is ignored", + "Ignoring an old cancellation preserves renewal", + "Conflicting expiry is not consumed", + "Outbox failure rolls back subscription state", + "Failed transaction leaves the observation retryable", + "Exhausted retries enter dead-letter", + "Grant failure rejects binding", + "Grant failure rolls back ownership", + "An expired purchase can be bound", + "Binding expired evidence emits no grant", + "Delayed binding retains the store occurrence", + "Delayed binding records its actual processing time", + "Late expiry revokes a persisted grant exactly once", + "Repeated expiry emits no second revocation", + "Equal-time expiry retains its lifecycle transition", + "Equal-time observations revoke once and preserve both transitions", + "Health accepts the proxy public Host header", + "subscription.started: authenticated and saved", + "subscription.started: redelivery deduplicated", + "subscription.started: tampering rejected", + "entitlement.granted: authenticated and saved", + "entitlement.granted: redelivery deduplicated", + "entitlement.granted: tampering rejected", + "subscription.renewed: authenticated and saved", + "subscription.renewed: redelivery deduplicated", + "subscription.renewed: tampering rejected", + "subscription.canceled: authenticated and saved", + "subscription.canceled: redelivery deduplicated", + "subscription.canceled: tampering rejected", + "subscription.expired: authenticated and saved", + "subscription.expired: redelivery deduplicated", + "subscription.expired: tampering rejected", + "entitlement.revoked: authenticated and saved", + "entitlement.revoked: redelivery deduplicated", + "entitlement.revoked: tampering rejected", + "subscription.refunded: authenticated and saved", + "subscription.refunded: redelivery deduplicated", + "subscription.refunded: tampering rejected", + "One inbox record per event", + "Inbox survives reopening SQLite", + "apple: maps evidence without forwarding client identity", + "apple: matches the installed verification input schema", + "google: maps evidence without forwarding client identity", + "google: matches the installed verification input schema", + "amazon: uses server-selected store identity, distinct from the app user", + "amazon: matches the installed verification input schema", + "horizon: uses server-selected store identity, distinct from the app user", + "horizon: matches the installed verification input schema", + "Rejects missing purchase", + "Rejects unknown store", + "Rejects Amazon requires its authenticated store user", + "Rejects Horizon requires its authenticated store user", + "Rejects missing evidence", + "Rejects blank evidence", + "Rejects non-string evidence", + "Rejects oversized evidence", + "apple: unverified evidence cannot bind", + "apple: matching evidence verifies", + "apple: verification alone gives no access", + "apple: verification credentials cannot bind", + "apple: binding and retry keep one owner", + "apple: binding and retry keep one owner", + "apple: another account cannot claim the purchase", + "apple: owned product is accessible", + "apple: erasure completes", + "apple: erasure removes access", + "apple: erased evidence cannot be claimed", + "apple: verification before the fixture deadline respects the store's access model", + "apple: verification at the fixture deadline respects the store's access model", + "apple: verification after the fixture deadline respects the store's access model", + "google: unverified evidence cannot bind", + "google: matching evidence verifies", + "google: verification alone gives no access", + "google: verification credentials cannot bind", + "google: binding and retry keep one owner", + "google: binding and retry keep one owner", + "google: another account cannot claim the purchase", + "google: owned product is accessible", + "google: erasure completes", + "google: erasure removes access", + "google: erased evidence cannot be claimed", + "google: verification before the fixture deadline respects the store's access model", + "google: verification at the fixture deadline respects the store's access model", + "google: verification after the fixture deadline respects the store's access model", + "amazon: unverified evidence cannot bind", + "amazon: matching evidence verifies", + "amazon: verification alone gives no access", + "amazon: verification credentials cannot bind", + "amazon: binding and retry keep one owner", + "amazon: binding and retry keep one owner", + "amazon: another account cannot claim the purchase", + "amazon: owned product is accessible", + "amazon: an outage is an error, not cached access", + "amazon: a negative recheck removes access", + "amazon: a confirmed recheck restores ownership", + "amazon: erasure completes", + "amazon: erasure removes access", + "amazon: erased evidence cannot be claimed", + "amazon: verification before the fixture deadline respects the store's access model", + "amazon: verification at the fixture deadline respects the store's access model", + "amazon: verification after the fixture deadline respects the store's access model", + "amazon: a negative ownership verdict remains rejected after the fixture deadline", + "amazon: an ownership verification outage remains an error after the fixture deadline", + "horizon: unverified evidence cannot bind", + "horizon: matching evidence verifies", + "horizon: verification alone gives no access", + "horizon: verification credentials cannot bind", + "horizon: binding and retry keep one owner", + "horizon: binding and retry keep one owner", + "horizon: another account cannot claim the purchase", + "horizon: owned product is accessible", + "horizon: an outage is an error, not cached access", + "horizon: a negative recheck removes access", + "horizon: a confirmed recheck restores ownership", + "horizon: erasure completes", + "horizon: erasure removes access", + "horizon: erased evidence cannot be claimed", + "horizon: verification before the fixture deadline respects the store's access model", + "horizon: verification at the fixture deadline respects the store's access model", + "horizon: verification after the fixture deadline respects the store's access model", + "horizon: a negative ownership verdict remains rejected after the fixture deadline", + "horizon: an ownership verification outage remains an error after the fixture deadline", + "An active purchase has a delivered event copy", + "Erasure during delivery completes", + "An in-flight event cannot resurrect receiver data", + "An in-flight acknowledgement cannot resurrect the outbox", + "Repeated erase survives restart", + "An old signed event remains discarded after restart", + "A new event ID cannot bypass erasure", + "Verification cannot bind an erased purchase", + "Stale binding cannot restore an erased account", + "Another account cannot claim erased evidence", + "Erased account is inactive before paid expiry", + "Late lifecycle processing carries no erased identity", + "Persisted protocol records contain no erased user ID", + "Unknown-user erasure is accepted", + "Verification credentials cannot erase users", + "Provider erasure completes", + "Erasure retry after restart returns the same job", + "Purchase has no account identity", + "App event copies are erased", + "Erased account has no access", + "A stale binding retry cannot restore identity" + ], + "history": [ + { + "step": 1, + "title": "Start with the contract", + "built": "HTTP routes + schema validation + SQLite", + "result": "A running server, an empty purchase table, and no access.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "providerCapabilities", + "httpStatus": 200, + "body": { + "specVersion": "1.0", + "implementation": { + "name": "Commerce Protocol Example — fictional fixture store" + }, + "eventTypes": [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked" + ], + "stores": { + "fixture": { + "initialValidation": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "serverNotifications": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "subscriptions": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "renewalEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "refundEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "expiration": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "reconciliation": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "entitlements": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "revenueAmount": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + } + } + } + } + }, + { + "operation": "providerCapabilities", + "httpStatus": 200, + "body": { + "specVersion": "1.0", + "implementation": { + "name": "Commerce Protocol Example — fictional fixture store" + }, + "eventTypes": [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked" + ], + "stores": { + "fixture": { + "initialValidation": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "serverNotifications": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "subscriptions": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "renewalEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "refundEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "expiration": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "reconciliation": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "entitlements": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "revenueAmount": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + } + } + } + } + } + ], + "checks": [ + "Fixture request matches the installed schema", + "Capabilities use the published response schema", + "Purchase storage starts empty", + "No profile conformance is claimed" + ], + "totalChecks": 4 + }, + { + "step": 2, + "title": "Verify a purchase", + "built": "Fixture store adapter + purchase persistence", + "result": "Valid evidence is saved. Alice still has no access.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [ + { + "userId": null, + "productId": "premium.monthly", + "state": "Active", + "willRenew": 1 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "verifyPurchase", + "httpStatus": 200, + "body": { + "store": "fixture", + "isValid": true, + "state": "ENTITLED", + "productId": "premium.monthly", + "environment": "local-fixture" + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "verifyPurchase", + "httpStatus": 200, + "body": { + "store": "fixture", + "isValid": false, + "state": "INAUTHENTIC", + "environment": "local-fixture" + } + }, + { + "operation": "verifyPurchase", + "httpStatus": 502, + "body": { + "error": { + "code": "VERIFICATION_FAILED", + "message": "verification failed" + } + } + } + ], + "checks": [ + "Fixture evidence is accepted", + "Verification does not grant access", + "Invalid evidence produces a negative verdict", + "An upstream outage is not a negative verdict" + ], + "totalChecks": 8 + }, + { + "step": 3, + "title": "Connect it to a user", + "built": "Server authorization + atomic binding + entitlement reads", + "result": "Alice gets Premium. Another user cannot take the purchase.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 1 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": true + } + ] + }, + "deliveries": [ + { + "eventId": "e35c1b0e-6e26-467c-8a42-e694827025ab", + "deliveryId": "49b51c2e-43b7-41c1-b556-fcac91edd423", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + } + ], + "inboxCount": 0, + "responses": [ + { + "operation": "bindPurchase", + "httpStatus": 403, + "body": { + "error": { + "code": "FORBIDDEN", + "message": "forbidden" + } + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": true + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": true + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": false + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": true + } + ] + } + } + ], + "checks": [ + "Verification credentials cannot bind a user", + "The server binds Alice", + "Repeating the same binding succeeds", + "Bob cannot take Alice's purchase", + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none" + ], + "totalChecks": 14 + }, + { + "step": 4, + "title": "Handle cancellation", + "built": "Lifecycle processing + transactional event outbox", + "result": "Renewal stops. Alice keeps the time she already paid for.", + "simulatedTime": "2026-09-08T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + ] + }, + "deliveries": [ + { + "eventId": "e35c1b0e-6e26-467c-8a42-e694827025ab", + "deliveryId": "49b51c2e-43b7-41c1-b556-fcac91edd423", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + }, + { + "eventId": "4ac175da-c8fd-4cd3-9e9d-e012ec585aec", + "deliveryId": "2cdd11db-8156-408a-a659-71c3e4d3c805", + "attempts": 0, + "status": "pending", + "eventType": "subscription.canceled" + } + ], + "inboxCount": 0, + "responses": [ + { + "operation": "subscriptionStatus", + "httpStatus": 200, + "body": { + "active": true, + "subscription": { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + } + } + ], + "checks": [ + "Cancellation keeps paid access", + "Cancellation stops renewal", + "Cancellation queues one event" + ], + "totalChecks": 17 + }, + { + "step": 5, + "title": "Deliver, retry, deduplicate", + "built": "HMAC signatures + retry worker + durable receiver inbox", + "result": "A 503 retries successfully. Redelivery creates no second inbox row.", + "simulatedTime": "2026-09-08T09:00:31.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + ] + }, + "deliveries": [ + { + "eventId": "e35c1b0e-6e26-467c-8a42-e694827025ab", + "deliveryId": "49b51c2e-43b7-41c1-b556-fcac91edd423", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "4ac175da-c8fd-4cd3-9e9d-e012ec585aec", + "deliveryId": "2cdd11db-8156-408a-a659-71c3e4d3c805", + "attempts": 3, + "status": "delivered", + "eventType": "subscription.canceled" + } + ], + "inboxCount": 2, + "responses": [ + { + "operation": "webhook: receiver unavailable", + "body": [ + { + "eventId": "e35c1b0e-6e26-467c-8a42-e694827025ab", + "deliveryId": "49b51c2e-43b7-41c1-b556-fcac91edd423", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + }, + { + "eventId": "4ac175da-c8fd-4cd3-9e9d-e012ec585aec", + "deliveryId": "2cdd11db-8156-408a-a659-71c3e4d3c805", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + } + ] + }, + { + "operation": "webhook: retry after restart", + "body": [ + { + "eventId": "e35c1b0e-6e26-467c-8a42-e694827025ab", + "deliveryId": "49b51c2e-43b7-41c1-b556-fcac91edd423", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + }, + { + "eventId": "4ac175da-c8fd-4cd3-9e9d-e012ec585aec", + "deliveryId": "2cdd11db-8156-408a-a659-71c3e4d3c805", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + } + ] + }, + { + "operation": "webhook: lost-ack redelivery", + "body": [ + { + "eventId": "e35c1b0e-6e26-467c-8a42-e694827025ab", + "deliveryId": "49b51c2e-43b7-41c1-b556-fcac91edd423", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + }, + { + "eventId": "4ac175da-c8fd-4cd3-9e9d-e012ec585aec", + "deliveryId": "2cdd11db-8156-408a-a659-71c3e4d3c805", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + } + ] + } + ], + "checks": [ + "A 503 leaves a durable retry", + "Retry survives provider restart", + "Retry keeps the delivery identity", + "Redelivery has one inbox row per event" + ], + "totalChecks": 21 + }, + { + "step": 6, + "title": "Expire access and restart", + "built": "Expiry-aware reads + recovery from SQLite", + "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", + "simulatedTime": "2026-10-07T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Expired", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [ + { + "eventId": "e35c1b0e-6e26-467c-8a42-e694827025ab", + "deliveryId": "49b51c2e-43b7-41c1-b556-fcac91edd423", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "4ac175da-c8fd-4cd3-9e9d-e012ec585aec", + "deliveryId": "2cdd11db-8156-408a-a659-71c3e4d3c805", + "attempts": 3, + "status": "delivered", + "eventType": "subscription.canceled" + }, + { + "eventId": "b6682068-ac20-4dac-9633-0ae163d9cf6c", + "deliveryId": "d580725d-df1a-440c-bbc2-ae1151f9ec7b", + "attempts": 1, + "status": "delivered", + "eventType": "subscription.expired" + }, + { + "eventId": "39add747-d685-4510-b7d1-e791997f6949", + "deliveryId": "d08b5e9a-ed5e-4c6e-9ba4-7233af44928d", + "attempts": 1, + "status": "delivered", + "eventType": "entitlement.revoked" + } + ], + "inboxCount": 4, + "responses": [ + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "webhook: expiry + revocation", + "body": [ + { + "eventId": "b6682068-ac20-4dac-9633-0ae163d9cf6c", + "deliveryId": "d580725d-df1a-440c-bbc2-ae1151f9ec7b", + "httpStatus": 200, + "attempt": 1, + "status": "delivered" + }, + { + "eventId": "39add747-d685-4510-b7d1-e791997f6949", + "deliveryId": "d08b5e9a-ed5e-4c6e-9ba4-7233af44928d", + "httpStatus": 200, + "attempt": 1, + "status": "delivered" + } + ] + }, + { + "operation": "subscriptionStatus", + "httpStatus": 200, + "body": { + "active": false, + "subscription": { + "productId": "premium.monthly", + "state": "Expired", + "active": false, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + } + } + ], + "checks": [ + "Access closes at expiry before a notification arrives", + "Duplicate store notification emits no extra event", + "Reopening both databases preserves state", + "Receiver deduplication survives restart", + "All four events reach the receiver", + "The final status is inactive" + ], + "totalChecks": 27 + }, + { + "step": 7, + "title": "Delete the account", + "built": "Idempotent erasure + receiver cleanup + durable deletion guard", + "result": "Alice is removed from purchases and event copies. Late deliveries cannot restore her account data.", + "simulatedTime": "2026-10-07T09:00:00.000Z", + "purchases": [ + { + "userId": null, + "productId": "premium.monthly", + "state": "Expired", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "eraseUser", + "httpStatus": 403, + "body": { + "error": { + "code": "FORBIDDEN", + "message": "forbidden" + } + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "f350d8b6-da5f-4374-9af4-31cbd392efbb", + "status": "completed" + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "f350d8b6-da5f-4374-9af4-31cbd392efbb", + "status": "completed" + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": false + } + } + ], + "checks": [ + "Verification credentials cannot erase users", + "Provider erasure completes", + "Erasure retry after restart returns the same job", + "Purchase has no account identity", + "App event copies are erased", + "Erased account has no access", + "A stale binding retry cannot restore identity" + ], + "totalChecks": 34 + } + ], + "screenshot": "screen.png", + "scope": "Incremental implementation of the installed Commerce Protocol contract. HTTP, SQLite and signatures execute locally; store and clock are fixtures. No full profile claim." +} diff --git a/docs/build/07-account-erasure-interoperable-6/screen.png b/docs/build/07-account-erasure-interoperable-6/screen.png new file mode 100644 index 0000000..982e657 Binary files /dev/null and b/docs/build/07-account-erasure-interoperable-6/screen.png differ diff --git a/docs/build/07-account-erasure-interoperable-6/source.tar.gz b/docs/build/07-account-erasure-interoperable-6/source.tar.gz new file mode 100644 index 0000000..6c3755c Binary files /dev/null and b/docs/build/07-account-erasure-interoperable-6/source.tar.gz differ diff --git a/docs/build/07-account-erasure-interoperable/attempt-1.txt b/docs/build/07-account-erasure-interoperable/attempt-1.txt new file mode 100644 index 0000000..84ac558 --- /dev/null +++ b/docs/build/07-account-erasure-interoperable/attempt-1.txt @@ -0,0 +1,20 @@ +{ + "startedAt": "2026-09-08T19:44:05.187Z", + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 642ms\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && bun test composition/app-backend.test.mjs composition/receiver.test.mjs\n\nCommerce Protocol Example: 159 checks passed. No store or production service contacted.\nbun test v1.3.13 (bf2e2cec)\n\ncomposition/app-backend.test.mjs:\n(pass) account deletion waits for in-flight fulfillment and retries provider erasure after app restart [157.46ms]\n\ncomposition/receiver.test.mjs:\n(pass) one inbox scopes event IDs to authenticated emitters, survives upgrade, and discards erased users [4.32ms]\n\n 2 pass\n 0 fail\n 19 expect() calls\nRan 2 tests across 2 files. [243.00ms]\n" + }, + { + "command": "npm run test:tooling", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [15.19ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [59.68ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.48ms]\n(pass) unfinished captures do not hide completed records [0.91ms]\n\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [112.00ms]\n" + } + ] +} diff --git a/docs/build/07-account-erasure-interoperable/changes.patch b/docs/build/07-account-erasure-interoperable/changes.patch new file mode 100644 index 0000000..cdc9395 --- /dev/null +++ b/docs/build/07-account-erasure-interoperable/changes.patch @@ -0,0 +1,341 @@ +diff --git a/checkpoint-tools.mjs b/checkpoint-tools.mjs +index 6e6cfd4..112a019 100644 +--- a/checkpoint-tools.mjs ++++ b/checkpoint-tools.mjs +@@ -41,6 +41,7 @@ export const SOURCE_FILES = [ + "composition/README.md", + "composition/app-backend.mjs", + "composition/app-backend.test.mjs", ++ "composition/receiver.test.mjs", + "composition/commerce-client.mjs", + "composition/export.mjs", + "composition/memory-provider.mjs", +diff --git a/checkpoint.json b/checkpoint.json +index 75c152c..4860ddb 100644 +--- a/checkpoint.json ++++ b/checkpoint.json +@@ -1,8 +1,8 @@ + { + "step": 7, +- "id": "07-account-erasure-reviewed", ++ "id": "07-account-erasure-interoperable", + "title": "Delete the account", +- "built": "Account erasure with readable identity state", ++ "built": "Account erasure with shared receiver and retry coordination", + "result": "The account identity is removed; retries and restart cannot restore it.", +- "previous": "07-account-erasure" ++ "previous": "07-account-erasure-reviewed" + } +diff --git a/composition/README.md b/composition/README.md +index 00ffd08..f0da5f6 100644 +--- a/composition/README.md ++++ b/composition/README.md +@@ -19,7 +19,7 @@ required. The runner creates its fixture credentials, webhook keys, temporary + databases, and local HTTP ports, then cleans up after itself. Installing + dependencies needs registry access; the composition run uses loopback HTTP only. + +-The existing six-step dashboard remains available through `npm start` at ++The seven-step dashboard, including account deletion, remains available through `npm start` at + `http://127.0.0.1:5181`. Set `COMMERCE_LAB_PORT` only to change that port. + + ## Follow the code +@@ -49,7 +49,10 @@ and an optional `extensions["partner.segment"]` string survives storage without + consumer. The consumer also rejects a malformed successful API response. + + Each emitter/project has its own configured endpoint, secret, and inbox +-database. Equal event IDs from different emitters therefore do not collide. ++database in this baseline run. For the IAPKit replacement run, `startAppBackend` ++keeps one application endpoint and one receiver alive. Configure that receiver ++with named emitters and separate signing keys bound to each project ID; equal ++event IDs from different providers then remain distinct in the same inbox. + This does not identify duplicate real-world facts across a provider cutover. + + ## Make and verify one change +diff --git a/composition/app-backend.mjs b/composition/app-backend.mjs +index 04475a7..1d63f4f 100644 +--- a/composition/app-backend.mjs ++++ b/composition/app-backend.mjs +@@ -21,6 +21,7 @@ export function startAppBackend({ path, providers, receiver, resolveSession }) { + for (const row of db.query("SELECT * FROM erasure_requests").all()) { + try { + receiver.eraseUser(row.user_id); ++ await Promise.allSettled([...(inFlight.get(row.user_id) ?? [])]); + const result = await clients[row.provider].call("eraseUser", { + userId: row.user_id, + }); +@@ -83,7 +84,6 @@ export function startAppBackend({ path, providers, receiver, resolveSession }) { + ).run(userId, name); + })(); + receiver.eraseUser(userId); +- await Promise.allSettled([...(inFlight.get(userId) ?? [])]); + const pending = await drainErasure(); + return Response.json({ + accepted: true, +diff --git a/composition/app-backend.test.mjs b/composition/app-backend.test.mjs +index 7f2da7c..dc43f6c 100644 +--- a/composition/app-backend.test.mjs ++++ b/composition/app-backend.test.mjs +@@ -19,7 +19,8 @@ test("account deletion waits for in-flight fulfillment and retries provider eras + const gate = new Promise((resolve) => { + releaseBind = resolve; + }); +- let rejectErase = true; ++ let rejectErase = true, ++ eraseCalls = 0; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, +@@ -28,8 +29,10 @@ test("account deletion waits for in-flight fulfillment and retries provider eras + signalBind(); + await gate; + } +- if (new URL(request.url).pathname.endsWith("/erase") && rejectErase) +- return new Response(null, { status: 503 }); ++ if (new URL(request.url).pathname.endsWith("/erase")) { ++ eraseCalls++; ++ if (rejectErase) return new Response(null, { status: 503 }); ++ } + return provider.fetch(request); + }, + }); +@@ -65,12 +68,21 @@ test("account deletion waits for in-flight fulfillment and retries provider eras + await Bun.sleep(10); + } + expect((await fetch(app.url + "/access")).status).toBe(401); ++ let workerFinished = false; ++ const background = app.drainErasure().then((pending) => { ++ workerFinished = true; ++ return pending; ++ }); ++ await Bun.sleep(20); ++ expect(workerFinished).toBe(false); ++ expect(eraseCalls).toBe(0); + releaseBind(); + expect(await (await purchase).json()).toEqual({ access: false }); + expect(await (await deletion).json()).toEqual({ + accepted: true, + status: "queued", + }); ++ expect(await background).toBe(1); + await app.close(); + app = startAppBackend(options); + expect( +diff --git a/composition/receiver.test.mjs b/composition/receiver.test.mjs +new file mode 100644 +index 0000000..24e83b4 +--- /dev/null ++++ b/composition/receiver.test.mjs +@@ -0,0 +1,97 @@ ++import { test, expect } from "bun:test"; ++import { Database } from "bun:sqlite"; ++import { mkdtempSync, rmSync } from "node:fs"; ++import { tmpdir } from "node:os"; ++import { join } from "node:path"; ++import { WEBHOOK, COMMERCE_EVENT_VERSION } from "openiap-commerce-protocol"; ++import { createReceiver, sign } from "../webhooks.mjs"; ++ ++test("one inbox scopes event IDs to authenticated emitters, survives upgrade, and discards erased users", async () => { ++ const directory = mkdtempSync(join(tmpdir(), "commerce-receiver-")); ++ const path = join(directory, "inbox.sqlite"); ++ const now = Date.now(); ++ const emitters = [ ++ { name: "example", projectId: "example_project", secret: "example-key" }, ++ { name: "iapkit", projectId: "kit_project", secret: "kit-key" }, ++ ]; ++ const event = { ++ eventId: "same-event-id", ++ eventType: "subscription.canceled", ++ eventVersion: COMMERCE_EVENT_VERSION, ++ occurredAt: now, ++ processedAt: now, ++ store: "fixture", ++ environment: "local-fixture", ++ projectId: emitters[0].projectId, ++ userId: "alice", ++ productId: "premium.monthly", ++ subscription: { ++ productId: "premium.monthly", ++ state: "Active", ++ active: true, ++ expiresAt: now + 60000, ++ willRenew: false, ++ }, ++ }; ++ const old = new Database(path, { create: true }); ++ old.exec( ++ "CREATE TABLE inbox (event_id TEXT PRIMARY KEY, body TEXT NOT NULL)", ++ ); ++ old ++ .query("INSERT INTO inbox VALUES (?, ?)") ++ .run(event.eventId, JSON.stringify(event)); ++ old.close(); ++ let receiver = createReceiver(path, emitters, () => now); ++ const post = (emitter, value) => { ++ const body = JSON.stringify(value), ++ timestamp = String(Math.floor(now / 1000)); ++ return receiver.fetch( ++ new Request("http://localhost/webhooks/commerce", { ++ method: "POST", ++ body, ++ headers: { ++ [WEBHOOK.timestampHeader]: timestamp, ++ [WEBHOOK.signatureHeader]: sign(emitter.secret, timestamp, body), ++ [WEBHOOK.eventIdHeader]: value.eventId, ++ }, ++ }), ++ ); ++ }; ++ try { ++ expect(await (await post(emitters[0], event)).json()).toEqual({ ++ accepted: true, ++ duplicate: true, ++ }); ++ const kitEvent = { ...event, projectId: emitters[1].projectId }; ++ expect((await post(emitters[0], kitEvent)).status).toBe(401); ++ expect(receiver.count()).toBe(1); ++ expect(await (await post(emitters[1], kitEvent)).json()).toEqual({ ++ accepted: true, ++ duplicate: false, ++ }); ++ expect(receiver.count()).toBe(2); ++ receiver.close(); ++ receiver = createReceiver(path, emitters, () => now); ++ expect(await (await post(emitters[1], kitEvent)).json()).toEqual({ ++ accepted: true, ++ duplicate: true, ++ }); ++ expect(receiver.eraseUser("alice")).toBe(2); ++ receiver.close(); ++ receiver = createReceiver(path, emitters, () => now); ++ for (const emitter of emitters) ++ expect( ++ await ( ++ await post(emitter, { ++ ...event, ++ projectId: emitter.projectId, ++ eventId: "late-event", ++ }) ++ ).json(), ++ ).toEqual({ accepted: true, discarded: "erased-user" }); ++ expect(receiver.count()).toBe(0); ++ } finally { ++ receiver.close(); ++ rmSync(directory, { recursive: true, force: true }); ++ } ++}); +diff --git a/consumer.mjs b/consumer.mjs +index cb052b8..1e240f3 100644 +--- a/consumer.mjs ++++ b/consumer.mjs +@@ -7,7 +7,7 @@ import { WEBHOOK, COMMERCE_EVENT_VERSION } from "openiap-commerce-protocol"; + import { createReceiver, sign } from "./webhooks.mjs"; + import { validate } from "./contract.mjs"; + +-// One configured emitter/project and signing key per receiver database. ++// One app inbox; named emitters can bind separate signing keys to project IDs. + export function startConsumer({ secret, path, port = 0, now = Date.now }) { + assert(secret, "Set COMMERCE_WEBHOOK_SECRET to the provider signing secret."); + let receiver = createReceiver(path, secret, now); +diff --git a/package.json b/package.json +index d920e9e..8186b2e 100644 +--- a/package.json ++++ b/package.json +@@ -5,7 +5,7 @@ + "type": "module", + "scripts": { + "start": "bun server.mjs", +- "test": "bun verify.mjs && bun test composition/app-backend.test.mjs", ++ "test": "bun verify.mjs && bun test composition/app-backend.test.mjs composition/receiver.test.mjs", + "capture": "bun capture.mjs", + "verify:checkpoints": "bun verify-checkpoints.mjs", + "test:tooling": "bun test checkpoint-tools.test.mjs", +diff --git a/webhooks.mjs b/webhooks.mjs +index d8e18c4..f9b059d 100644 +--- a/webhooks.mjs ++++ b/webhooks.mjs +@@ -34,23 +34,58 @@ export function authentic(secrets, timestamp, body, signatures, nowSeconds) { + } + + export function createReceiver(path, secret, now) { ++ const emitters = ++ typeof secret === "string" ? [{ name: "default", secret }] : secret; ++ if ( ++ !Array.isArray(emitters) || ++ !emitters.length || ++ emitters.some( ++ (entry) => ++ !entry.name || ++ !entry.secret || ++ (typeof secret !== "string" && !entry.projectId), ++ ) ++ ) ++ throw new Error( ++ "Configure each emitter with a name, project ID, and signing secret", ++ ); ++ if (new Set(emitters.map((entry) => entry.name)).size !== emitters.length) ++ throw new Error("Emitter names must be unique"); + const db = new Database(path, { create: true }); + db.exec( + "CREATE TABLE IF NOT EXISTS inbox (event_id TEXT PRIMARY KEY, body TEXT NOT NULL)", + ); + const erasures = createErasureLedger(db); ++ // Upgrade old single-emitter event IDs without losing durable deduplication. ++ db.transaction(() => { ++ for (const row of db.query("SELECT event_id, body FROM inbox").all()) { ++ const event = JSON.parse(row.body); ++ if (row.event_id !== event.eventId) continue; ++ const matching = emitters.filter( ++ (entry) => entry.projectId === event.projectId, ++ ); ++ const name = matching.length === 1 ? matching[0].name : "default"; ++ const identity = JSON.stringify([name, event.projectId, event.eventId]); ++ db.query("INSERT OR IGNORE INTO inbox VALUES (?, ?)").run( ++ identity, ++ row.body, ++ ); ++ db.query("DELETE FROM inbox WHERE event_id = ?").run(row.event_id); ++ } ++ })(); + + async function fetch(request) { + const bytes = new Uint8Array(await request.arrayBuffer()); +- if ( +- !authentic( +- [secret], ++ const authenticated = emitters.filter((emitter) => ++ authentic( ++ [emitter.secret], + request.headers.get(WEBHOOK.timestampHeader), + bytes, + request.headers.get(WEBHOOK.signatureHeader), + Math.floor(now() / 1000), +- ) +- ) { ++ ), ++ ); ++ if (!authenticated.length) { + return new Response("Invalid signature", { status: 401 }); + } + let body, event; +@@ -64,12 +99,20 @@ export function createReceiver(path, secret, now) { + return new Response("Invalid event", { status: 400 }); + if (request.headers.get(WEBHOOK.eventIdHeader) !== event.eventId) + return new Response("Event ID mismatch", { status: 400 }); ++ const emitter = authenticated.find( ++ (entry) => !entry.projectId || entry.projectId === event.projectId, ++ ); ++ if (!emitter) ++ return new Response("Unexpected emitter project", { status: 401 }); + if (event.userId && erasures.has(event.userId)) + return Response.json({ accepted: true, discarded: "erased-user" }); + // Inbox insertion is the durable effect; downstream jobs can consume it later. + const result = db + .query("INSERT OR IGNORE INTO inbox VALUES (?, ?)") +- .run(event.eventId, body); ++ .run( ++ JSON.stringify([emitter.name, event.projectId, event.eventId]), ++ body, ++ ); + return Response.json({ accepted: true, duplicate: result.changes === 0 }); + } + diff --git a/docs/build/07-account-erasure-interoperable/mobile.png b/docs/build/07-account-erasure-interoperable/mobile.png new file mode 100644 index 0000000..c5c65fa Binary files /dev/null and b/docs/build/07-account-erasure-interoperable/mobile.png differ diff --git a/docs/build/07-account-erasure-interoperable/run.json b/docs/build/07-account-erasure-interoperable/run.json new file mode 100644 index 0000000..7738c87 --- /dev/null +++ b/docs/build/07-account-erasure-interoperable/run.json @@ -0,0 +1,911 @@ +{ + "step": 7, + "id": "07-account-erasure-interoperable", + "title": "Delete the account", + "built": "Account erasure with shared receiver and retry coordination", + "result": "The account identity is removed; retries and restart cannot restore it.", + "previous": "07-account-erasure-reviewed", + "startedAt": "2026-09-08T19:44:05.187Z", + "recordedAt": "2026-09-08T19:44:17.930Z", + "packageVersion": "0.1.0", + "task": "# Review the completed backend\n\nApply the Fable 5.1 max CLI review to the final checkpoint. Add the missing\nfirst-binding grant event in the same transaction as ownership, reject a\npremature expiry without consuming its observation, and verify both rollback\nand expiry boundaries. Correct test labels to describe what they exercise.\n\nRepair patch generation without rewriting historical source or screenshots.\nBuild patches from the preceding archived source and verify their hashes.\nInstall and test each archive outside the monorepo with npm. Capture the revised\nfinal screen on desktop and mobile, then export only matching source evidence.\n\nKeep the original six checkpoints as history. Explain their incomplete discovery\nand missing grant behavior; do not describe them as conformant providers. Keep\nall changes uncommitted for maintainer review.\n\nApply the second review: retain gate delivery state for delayed expiry,\nretain store occurrence on delayed binding, preserve consecutive failure logs,\nand state actual package contents and runtime requirements. Add a ready-to-run\ngeneric event receiver for an existing backend, reusing the same receiver\nhandler. Demonstrate signed lifecycle ingestion, duplicate delivery, tampering,\nand persisted inbox recovery over real local HTTP. Keep all samples fictional.\n\nAdd composable integration roles: experience, commerce, and data. Ship a short\nAI integration brief and a backend helper that maps Apple/Google OpenIAP\npurchase fields into the installed verification schema. Test invalid inputs\nand exclude client-supplied identity. Keep paywall UI APIs product-specific,\ncurrent IAPKit-only client helpers explicit, and store/device proof separate\nfrom local fixtures. Reuse the existing consumer and contract validators.\n\nThe first bridge test failed because store evidence was nested under an extra\n`evidence` key. Keep the failure output, use the installed input schema's\ntop-level `apple`/`google` members, and rerun that boundary check.\n\nApply the third CLI review. Make the integration brief reachable from the docs\nsite with absolute setup, source, and build-brief links. Accept signed webhook\ndelivery behind a reverse proxy that preserves the public Host header. Keep\nunfinished captures from blocking completed history, retain equal-time fixture\ntransitions, record the observed duplicate response, and configure Yarn's\nnode-modules linker. Preserve the proxy failure and rerun the checks.\n\nPrepare the standalone example for its first public commit. Rewrite the README\naround clone, run, inspect, choose a role, and verify. Put receiver and capture\nsetup in repository documentation so the example works before the docs site is\ndeployed. Preserve every earlier archive. Record this documentation revision,\nverify all source archives again, and export a stable current-source download.\nAdd CI that tests runtime, tooling, archives and the documentation export. This\nrevision is a local publication review, not another completed external review.\n\nFix the first Linux CI failure without rewriting historical recordings. macOS\nAppleDouble metadata must not count as source. Exclude it on extraction, omit it\nfrom new archives, and add a portable extraction regression test. Capture the\ncorrected tooling, verify every source revision, and rerun GitHub CI.\n\nCorrect the final CLI review finding: exercise cancellation at the expiry\nobservation timestamp so the check reaches the expired-state guard. Preserve\nthe earlier capture, record this revision, and verify its archive and patch.\n\nApply the Codex review: authenticate webhook body bytes before UTF-8 decoding.\nReject altered UTF-8 and inserted BOM bytes with an unchanged signature. Reject\nauthentically signed malformed UTF-8 before storage, and accept correctly signed\nUnicode and BOM bodies. Keep the reproduced failure, preserve old checkpoints,\nthen capture and verify the corrected revision.\n", + "sourceHashes": { + ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", + ".yarnrc.yml": "473e6def86fc03638120e0c01d0c8bbab095677256460fa4ea763e5d4697f270", + "AGENTS.md": "38764ea47552533ab5db55f7ade8c0025e2d7af9163715ce89f2d719f5475712", + "BUILD.md": "abad05223f40512522487d83337755d5c3222fda4614e5c2ea92f45d13be977d", + "INTEGRATE.md": "532f25bc83d96350381c8c801caf5837777624095a3f519053133dffdbee1cd8", + "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", + "README.md": "f46856f23a859637f4488074744dbc4f16a7a42999e27491aa832bac2089cbec", + "ai-task.md": "d000a11054750accc6bab8ddbf6d2fc6b674b12c3821ccbfd45729469923b8fe", + "capture.mjs": "4940347c92713f47965400fa9dca0908372ec2b3627612fccf40eb6b636a85a2", + "checkpoint-tools.mjs": "3bcf0552fce743cdd68b597826c0bb5092e75a49059c8615023aaf7605a9fabd", + "checkpoint-tools.test.mjs": "9c71b5f95f33fe3352f20836a57d063ed5c680fa0837ae4de399bc820b5d3896", + "checkpoint.json": "3ae2ebc5abbc00fd4aaca060464d2f8c6ae159bda3c5b4051da8d2892d09f399", + "client-bridge.mjs": "a0058579b02c2f3a770c7ff7b59dfe41ce50761361d9a0c7a2df96ab1b6b4acf", + "composition/README.md": "99e302c7365cf3969f61be2c466f31f5cf0928b2f3f0351e45907666f0459a44", + "composition/app-backend.mjs": "b770db0706cc8fef6cd3a3d730245b25f0cc5bc5253a1c053b0923da53d875af", + "composition/app-backend.test.mjs": "9fbfc7fdb393b95bdf803dfb77e44a5d4311e5812e33a987b371a6b4a32a3b41", + "composition/commerce-client.mjs": "f1f1a056bc76801df25111cb12fdcde501d507a5461ef8b15bfe2f57979cd03c", + "composition/export.mjs": "ccc84dee42d59b1d2b09f16aec2b77a774765dd6fab0f341b01f1043a2e88225", + "composition/memory-provider.mjs": "edf6a7567ead756dc4a5c15d5b5b14f4d4314db31e3aa3c01c8ec543754fcc31", + "composition/purchase-flow.mjs": "ce6f98426c36eab346e900ac06ed31f2d387e542c518a48c92a67f7bae147ec8", + "composition/receiver.test.mjs": "86f677f98f5376ecd8c26a80c9bef1cfcdbfefeae968672cc056c07c338fc40b", + "composition/run.mjs": "f9a3eb73861369f87cc3fe53fb256b5bc21f7fb4724610061eacf68d0b08f191", + "consumer.mjs": "d8e168238270309157d52ca777fea30f1d37bae38b96e88148314c894bdb1b4c", + "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", + "dashboard.html": "50a19acc0810453fa2ef55f4e5e958b4ba428764fc5a5edc10e262f9c990e532", + "erasure.mjs": "019cb67fba68fe0dee0937db6bcbdec5036090a49672d7769de7ba91d52f7d24", + "export-docs.mjs": "47cf25c7ba1942fb833ac6b496c1e06b4ae06a61f6328330c6a6a4337c164f84", + "package-lock.json": "6f5333cc45203d6a27fec82c9370a8c25fef7faab591467dd2ff3c79acd6ae1c", + "package.json": "396fc93b7d50240739af2b817c172ffab8692245bd919de3b26ee713b56d455b", + "provider.mjs": "e5c498cf01beedab756058f62cbeb5a533477ebb9da1b6064d48f4003e524094", + "scenario.mjs": "b8ff9024afd67701b5f70916a61fa7d538eaec281aa4b5cc660e1c205932a32d", + "server.mjs": "b0f185a3954fbe1d7a4dd15f0dc8638afcf04e386ec730ae17a15860784d9e36", + "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", + "verify-erasure.mjs": "65feaff2e8eb0a3247ecdbc0f511c8eef5e07a4c2c63155b07409ba7cb19be88", + "verify.mjs": "942f1cd3f663dd84350a20fba459f707436a82c691469caf2c2f36155fbd5b72", + "webhooks.mjs": "4838bac7a9083440998409aa1ab543999495ea6a907036bc78ba2002feb588b2" + }, + "checks": [ + "Fixture request matches the installed schema", + "Capabilities use the published response schema", + "Purchase storage starts empty", + "No profile conformance is claimed", + "Fixture evidence is accepted", + "Verification does not grant access", + "Invalid evidence produces a negative verdict", + "An upstream outage is not a negative verdict", + "Verification credentials cannot bind a user", + "The server binds Alice", + "Repeating the same binding succeeds", + "Bob cannot take Alice's purchase", + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none", + "Cancellation keeps paid access", + "Cancellation stops renewal", + "Cancellation queues one event", + "A 503 leaves a durable retry", + "Retry survives provider restart", + "Retry keeps the delivery identity", + "Redelivery has one inbox row per event", + "Access closes at expiry before a notification arrives", + "Duplicate store notification emits no extra event", + "Reopening both databases preserves state", + "Receiver deduplication survives restart", + "All four events reach the receiver", + "The final status is inactive", + "Signature vector: single-key", + "Receiver accepts vector: single-key", + "Signature vector: retry-after-backoff", + "Receiver accepts vector: retry-after-backoff", + "Signature vector: raw-utf8-body", + "Receiver accepts vector: raw-utf8-body", + "Signature vector: during-rotation", + "Receiver accepts vector: during-rotation", + "Signature vector: minimal-event-omits-extensions", + "Receiver accepts vector: minimal-event-omits-extensions", + "Receiver rejects: tampered-body", + "Receiver rejects: wrong-secret", + "Receiver rejects: timestamp-outside-tolerance", + "Receiver rejects: timestamp-not-in-signed-material", + "Receiver rejects: retry-reuses-first-signature", + "Receiver rejects: garbage-appended-to-valid-signature", + "Active: before expiry", + "Active: at expiry", + "Active: no deadline", + "InGracePeriod: before expiry", + "InGracePeriod: at expiry", + "InGracePeriod: no deadline", + "InBillingRetry: before expiry", + "InBillingRetry: at expiry", + "InBillingRetry: no deadline", + "Paused: before expiry", + "Paused: at expiry", + "Paused: no deadline", + "Expired: before expiry", + "Expired: at expiry", + "Expired: no deadline", + "Revoked: before expiry", + "Revoked: at expiry", + "Revoked: no deadline", + "Refunded: before expiry", + "Refunded: at expiry", + "Refunded: no deadline", + "Unknown: before expiry", + "Unknown: at expiry", + "Unknown: no deadline", + "FutureState: before expiry", + "FutureState: at expiry", + "FutureState: no deadline", + "Missing credentials are refused", + "Verification role cannot enumerate users", + "Malformed input is refused", + "A real store is not falsely accepted", + "Erasure rejects verification credentials", + "Cancellation after expiry is ignored", + "Expired fixture evidence has an expired verdict", + "Tampered HTTP body has no inbox effect", + "Changed UTF-8 bytes cannot reuse a signature", + "Inserted UTF-8 BOM cannot reuse a signature", + "Authenticated malformed UTF-8 is rejected before storage", + "Authentic Unicode bytes are accepted and stored", + "Authentic BOM bytes are verified before decoding", + "Receiver still has exactly four events", + "Cross-origin demo mutations are refused", + "Overlapping HTTP ownership claims have one winner", + "A cancellation older than the active row is ignored", + "Ignoring an old cancellation preserves renewal", + "Conflicting expiry is not consumed", + "Outbox failure rolls back subscription state", + "Failed transaction leaves the observation retryable", + "Exhausted retries enter dead-letter", + "Grant failure rejects binding", + "Grant failure rolls back ownership", + "An expired purchase can be bound", + "Binding expired evidence emits no grant", + "Delayed binding retains the store occurrence", + "Delayed binding records its actual processing time", + "Late expiry revokes a persisted grant exactly once", + "Repeated expiry emits no second revocation", + "Equal-time expiry retains its lifecycle transition", + "Equal-time observations revoke once and preserve both transitions", + "Health accepts the proxy public Host header", + "subscription.started: authenticated and saved", + "subscription.started: redelivery deduplicated", + "subscription.started: tampering rejected", + "entitlement.granted: authenticated and saved", + "entitlement.granted: redelivery deduplicated", + "entitlement.granted: tampering rejected", + "subscription.renewed: authenticated and saved", + "subscription.renewed: redelivery deduplicated", + "subscription.renewed: tampering rejected", + "subscription.canceled: authenticated and saved", + "subscription.canceled: redelivery deduplicated", + "subscription.canceled: tampering rejected", + "subscription.expired: authenticated and saved", + "subscription.expired: redelivery deduplicated", + "subscription.expired: tampering rejected", + "entitlement.revoked: authenticated and saved", + "entitlement.revoked: redelivery deduplicated", + "entitlement.revoked: tampering rejected", + "subscription.refunded: authenticated and saved", + "subscription.refunded: redelivery deduplicated", + "subscription.refunded: tampering rejected", + "One inbox record per event", + "Inbox survives reopening SQLite", + "apple: maps evidence without forwarding client identity", + "apple: matches the installed verification input schema", + "google: maps evidence without forwarding client identity", + "google: matches the installed verification input schema", + "Rejects missing purchase", + "Rejects unknown store", + "Rejects Amazon needs its own adapter", + "Rejects Horizon needs its own adapter", + "Rejects missing evidence", + "Rejects blank evidence", + "Rejects non-string evidence", + "Rejects oversized evidence", + "An active purchase has a delivered event copy", + "Erasure during delivery completes", + "An in-flight event cannot resurrect receiver data", + "An in-flight acknowledgement cannot resurrect the outbox", + "Repeated erase survives restart", + "An old signed event remains discarded after restart", + "A new event ID cannot bypass erasure", + "Verification cannot bind an erased purchase", + "Stale binding cannot restore an erased account", + "Another account cannot claim erased evidence", + "Erased account is inactive before paid expiry", + "Late lifecycle processing carries no erased identity", + "Persisted protocol records contain no erased user ID", + "Unknown-user erasure is accepted", + "Verification credentials cannot erase users", + "Provider erasure completes", + "Erasure retry after restart returns the same job", + "Purchase has no account identity", + "App event copies are erased", + "Erased account has no access", + "A stale binding retry cannot restore identity" + ], + "history": [ + { + "step": 1, + "title": "Start with the contract", + "built": "HTTP routes + schema validation + SQLite", + "result": "A running server, an empty purchase table, and no access.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "providerCapabilities", + "httpStatus": 200, + "body": { + "specVersion": "1.0", + "implementation": { + "name": "Commerce Protocol Example — fictional fixture store" + }, + "eventTypes": [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked" + ], + "stores": { + "fixture": { + "initialValidation": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "serverNotifications": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "subscriptions": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "renewalEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "refundEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "expiration": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "reconciliation": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "entitlements": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "revenueAmount": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + } + } + } + } + }, + { + "operation": "providerCapabilities", + "httpStatus": 200, + "body": { + "specVersion": "1.0", + "implementation": { + "name": "Commerce Protocol Example — fictional fixture store" + }, + "eventTypes": [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked" + ], + "stores": { + "fixture": { + "initialValidation": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "serverNotifications": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "subscriptions": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "renewalEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "refundEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "expiration": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "reconciliation": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "entitlements": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "revenueAmount": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + } + } + } + } + } + ], + "checks": [ + "Fixture request matches the installed schema", + "Capabilities use the published response schema", + "Purchase storage starts empty", + "No profile conformance is claimed" + ], + "totalChecks": 4 + }, + { + "step": 2, + "title": "Verify a purchase", + "built": "Fixture store adapter + purchase persistence", + "result": "Valid evidence is saved. Alice still has no access.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [ + { + "userId": null, + "productId": "premium.monthly", + "state": "Active", + "willRenew": 1 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "verifyPurchase", + "httpStatus": 200, + "body": { + "store": "fixture", + "isValid": true, + "state": "ENTITLED", + "productId": "premium.monthly", + "environment": "local-fixture" + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "verifyPurchase", + "httpStatus": 200, + "body": { + "store": "fixture", + "isValid": false, + "state": "INAUTHENTIC", + "environment": "local-fixture" + } + }, + { + "operation": "verifyPurchase", + "httpStatus": 502, + "body": { + "error": { + "code": "VERIFICATION_FAILED", + "message": "verification failed" + } + } + } + ], + "checks": [ + "Fixture evidence is accepted", + "Verification does not grant access", + "Invalid evidence produces a negative verdict", + "An upstream outage is not a negative verdict" + ], + "totalChecks": 8 + }, + { + "step": 3, + "title": "Connect it to a user", + "built": "Server authorization + atomic binding + entitlement reads", + "result": "Alice gets Premium. Another user cannot take the purchase.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 1 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": true + } + ] + }, + "deliveries": [ + { + "eventId": "20ca80eb-0148-4391-aab9-05f7f6533390", + "deliveryId": "05b42876-9a23-4298-9996-f0b15e8ded04", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + } + ], + "inboxCount": 0, + "responses": [ + { + "operation": "bindPurchase", + "httpStatus": 403, + "body": { + "error": { + "code": "FORBIDDEN", + "message": "forbidden" + } + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": true + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": true + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": false + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": true + } + ] + } + } + ], + "checks": [ + "Verification credentials cannot bind a user", + "The server binds Alice", + "Repeating the same binding succeeds", + "Bob cannot take Alice's purchase", + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none" + ], + "totalChecks": 14 + }, + { + "step": 4, + "title": "Handle cancellation", + "built": "Lifecycle processing + transactional event outbox", + "result": "Renewal stops. Alice keeps the time she already paid for.", + "simulatedTime": "2026-09-08T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + ] + }, + "deliveries": [ + { + "eventId": "20ca80eb-0148-4391-aab9-05f7f6533390", + "deliveryId": "05b42876-9a23-4298-9996-f0b15e8ded04", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + }, + { + "eventId": "a4b46e81-5ebc-46e5-ba6b-9c0703e61e0f", + "deliveryId": "2437d322-c20f-4b60-8b98-189254a64262", + "attempts": 0, + "status": "pending", + "eventType": "subscription.canceled" + } + ], + "inboxCount": 0, + "responses": [ + { + "operation": "subscriptionStatus", + "httpStatus": 200, + "body": { + "active": true, + "subscription": { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + } + } + ], + "checks": [ + "Cancellation keeps paid access", + "Cancellation stops renewal", + "Cancellation queues one event" + ], + "totalChecks": 17 + }, + { + "step": 5, + "title": "Deliver, retry, deduplicate", + "built": "HMAC signatures + retry worker + durable receiver inbox", + "result": "A 503 retries successfully. Redelivery creates no second inbox row.", + "simulatedTime": "2026-09-08T09:00:31.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + ] + }, + "deliveries": [ + { + "eventId": "20ca80eb-0148-4391-aab9-05f7f6533390", + "deliveryId": "05b42876-9a23-4298-9996-f0b15e8ded04", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "a4b46e81-5ebc-46e5-ba6b-9c0703e61e0f", + "deliveryId": "2437d322-c20f-4b60-8b98-189254a64262", + "attempts": 3, + "status": "delivered", + "eventType": "subscription.canceled" + } + ], + "inboxCount": 2, + "responses": [ + { + "operation": "webhook: receiver unavailable", + "body": [ + { + "eventId": "20ca80eb-0148-4391-aab9-05f7f6533390", + "deliveryId": "05b42876-9a23-4298-9996-f0b15e8ded04", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + }, + { + "eventId": "a4b46e81-5ebc-46e5-ba6b-9c0703e61e0f", + "deliveryId": "2437d322-c20f-4b60-8b98-189254a64262", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + } + ] + }, + { + "operation": "webhook: retry after restart", + "body": [ + { + "eventId": "20ca80eb-0148-4391-aab9-05f7f6533390", + "deliveryId": "05b42876-9a23-4298-9996-f0b15e8ded04", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + }, + { + "eventId": "a4b46e81-5ebc-46e5-ba6b-9c0703e61e0f", + "deliveryId": "2437d322-c20f-4b60-8b98-189254a64262", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + } + ] + }, + { + "operation": "webhook: lost-ack redelivery", + "body": [ + { + "eventId": "20ca80eb-0148-4391-aab9-05f7f6533390", + "deliveryId": "05b42876-9a23-4298-9996-f0b15e8ded04", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + }, + { + "eventId": "a4b46e81-5ebc-46e5-ba6b-9c0703e61e0f", + "deliveryId": "2437d322-c20f-4b60-8b98-189254a64262", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + } + ] + } + ], + "checks": [ + "A 503 leaves a durable retry", + "Retry survives provider restart", + "Retry keeps the delivery identity", + "Redelivery has one inbox row per event" + ], + "totalChecks": 21 + }, + { + "step": 6, + "title": "Expire access and restart", + "built": "Expiry-aware reads + recovery from SQLite", + "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", + "simulatedTime": "2026-10-07T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Expired", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [ + { + "eventId": "20ca80eb-0148-4391-aab9-05f7f6533390", + "deliveryId": "05b42876-9a23-4298-9996-f0b15e8ded04", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "a4b46e81-5ebc-46e5-ba6b-9c0703e61e0f", + "deliveryId": "2437d322-c20f-4b60-8b98-189254a64262", + "attempts": 3, + "status": "delivered", + "eventType": "subscription.canceled" + }, + { + "eventId": "1732c2c4-8342-4aec-9a70-ca35274b0bc9", + "deliveryId": "40fd5b81-ff7e-4e5e-b54d-388b346dc4e6", + "attempts": 1, + "status": "delivered", + "eventType": "subscription.expired" + }, + { + "eventId": "25b6bb44-086a-47a2-9e7e-a2a6e6bb673d", + "deliveryId": "f42661ae-b8be-4f65-9e8d-f27f82b0950a", + "attempts": 1, + "status": "delivered", + "eventType": "entitlement.revoked" + } + ], + "inboxCount": 4, + "responses": [ + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "webhook: expiry + revocation", + "body": [ + { + "eventId": "1732c2c4-8342-4aec-9a70-ca35274b0bc9", + "deliveryId": "40fd5b81-ff7e-4e5e-b54d-388b346dc4e6", + "httpStatus": 200, + "attempt": 1, + "status": "delivered" + }, + { + "eventId": "25b6bb44-086a-47a2-9e7e-a2a6e6bb673d", + "deliveryId": "f42661ae-b8be-4f65-9e8d-f27f82b0950a", + "httpStatus": 200, + "attempt": 1, + "status": "delivered" + } + ] + }, + { + "operation": "subscriptionStatus", + "httpStatus": 200, + "body": { + "active": false, + "subscription": { + "productId": "premium.monthly", + "state": "Expired", + "active": false, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + } + } + ], + "checks": [ + "Access closes at expiry before a notification arrives", + "Duplicate store notification emits no extra event", + "Reopening both databases preserves state", + "Receiver deduplication survives restart", + "All four events reach the receiver", + "The final status is inactive" + ], + "totalChecks": 27 + }, + { + "step": 7, + "title": "Delete the account", + "built": "Idempotent erasure + receiver cleanup + durable deletion guard", + "result": "Alice is removed from purchases and event copies. Late deliveries cannot restore her account data.", + "simulatedTime": "2026-10-07T09:00:00.000Z", + "purchases": [ + { + "userId": null, + "productId": "premium.monthly", + "state": "Expired", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "eraseUser", + "httpStatus": 403, + "body": { + "error": { + "code": "FORBIDDEN", + "message": "forbidden" + } + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "7cc24fe5-4be6-4c5b-9864-9c882ba7d415", + "status": "completed" + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "7cc24fe5-4be6-4c5b-9864-9c882ba7d415", + "status": "completed" + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": false + } + } + ], + "checks": [ + "Verification credentials cannot erase users", + "Provider erasure completes", + "Erasure retry after restart returns the same job", + "Purchase has no account identity", + "App event copies are erased", + "Erased account has no access", + "A stale binding retry cannot restore identity" + ], + "totalChecks": 34 + } + ], + "screenshot": "screen.png", + "scope": "Incremental implementation of the installed Commerce Protocol contract. HTTP, SQLite and signatures execute locally; store and clock are fixtures. No full profile claim." +} diff --git a/docs/build/07-account-erasure-interoperable/screen.png b/docs/build/07-account-erasure-interoperable/screen.png new file mode 100644 index 0000000..982e657 Binary files /dev/null and b/docs/build/07-account-erasure-interoperable/screen.png differ diff --git a/docs/build/07-account-erasure-interoperable/source.tar.gz b/docs/build/07-account-erasure-interoperable/source.tar.gz new file mode 100644 index 0000000..d4330fb Binary files /dev/null and b/docs/build/07-account-erasure-interoperable/source.tar.gz differ diff --git a/docs/build/07-account-erasure-reviewed/attempt-1.txt b/docs/build/07-account-erasure-reviewed/attempt-1.txt new file mode 100644 index 0000000..9893b51 --- /dev/null +++ b/docs/build/07-account-erasure-reviewed/attempt-1.txt @@ -0,0 +1,20 @@ +{ + "startedAt": "2026-09-08T19:31:42.943Z", + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 452ms\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && bun test composition/app-backend.test.mjs\n\nCommerce Protocol Example: 159 checks passed. No store or production service contacted.\nbun test v1.3.13 (bf2e2cec)\n\ncomposition/app-backend.test.mjs:\n(pass) account deletion waits for in-flight fulfillment and retries provider erasure after app restart [75.21ms]\n\n 1 pass\n 0 fail\n 6 expect() calls\nRan 1 test across 1 file. [156.00ms]\n" + }, + { + "command": "npm run test:tooling", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [27.60ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [101.69ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.54ms]\n(pass) unfinished captures do not hide completed records [0.78ms]\n\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [161.00ms]\n" + } + ] +} diff --git a/docs/build/07-account-erasure-reviewed/changes.patch b/docs/build/07-account-erasure-reviewed/changes.patch new file mode 100644 index 0000000..1f5af0e --- /dev/null +++ b/docs/build/07-account-erasure-reviewed/changes.patch @@ -0,0 +1,29 @@ +diff --git a/checkpoint.json b/checkpoint.json +index 2b4698d..75c152c 100644 +--- a/checkpoint.json ++++ b/checkpoint.json +@@ -1,8 +1,8 @@ + { + "step": 7, +- "id": "07-account-erasure", ++ "id": "07-account-erasure-reviewed", + "title": "Delete the account", +- "built": "Atomic provider erasure, recipient cleanup and late-delivery guards", ++ "built": "Account erasure with readable identity state", + "result": "The account identity is removed; retries and restart cannot restore it.", +- "previous": "06-recover-reviewed-8" ++ "previous": "07-account-erasure" + } +diff --git a/dashboard.html b/dashboard.html +index 68bb6ee..4504686 100644 +--- a/dashboard.html ++++ b/dashboard.html +@@ -302,7 +302,7 @@ + for (const row of rows) { + const tr = body.insertRow(); + for (const [, key] of columns) +- tr.insertCell().textContent = String(row[key] ?? "Unbound"); ++ tr.insertCell().textContent = String(row[key] ?? (key === "userId" ? "No user identity" : "—")); + } + $(target).append(table); + } diff --git a/docs/build/07-account-erasure-reviewed/mobile.png b/docs/build/07-account-erasure-reviewed/mobile.png new file mode 100644 index 0000000..c5c65fa Binary files /dev/null and b/docs/build/07-account-erasure-reviewed/mobile.png differ diff --git a/docs/build/07-account-erasure-reviewed/run.json b/docs/build/07-account-erasure-reviewed/run.json new file mode 100644 index 0000000..792150d --- /dev/null +++ b/docs/build/07-account-erasure-reviewed/run.json @@ -0,0 +1,910 @@ +{ + "step": 7, + "id": "07-account-erasure-reviewed", + "title": "Delete the account", + "built": "Account erasure with readable identity state", + "result": "The account identity is removed; retries and restart cannot restore it.", + "previous": "07-account-erasure", + "startedAt": "2026-09-08T19:31:42.943Z", + "recordedAt": "2026-09-08T19:31:50.269Z", + "packageVersion": "0.1.0", + "task": "# Review the completed backend\n\nApply the Fable 5.1 max CLI review to the final checkpoint. Add the missing\nfirst-binding grant event in the same transaction as ownership, reject a\npremature expiry without consuming its observation, and verify both rollback\nand expiry boundaries. Correct test labels to describe what they exercise.\n\nRepair patch generation without rewriting historical source or screenshots.\nBuild patches from the preceding archived source and verify their hashes.\nInstall and test each archive outside the monorepo with npm. Capture the revised\nfinal screen on desktop and mobile, then export only matching source evidence.\n\nKeep the original six checkpoints as history. Explain their incomplete discovery\nand missing grant behavior; do not describe them as conformant providers. Keep\nall changes uncommitted for maintainer review.\n\nApply the second review: retain gate delivery state for delayed expiry,\nretain store occurrence on delayed binding, preserve consecutive failure logs,\nand state actual package contents and runtime requirements. Add a ready-to-run\ngeneric event receiver for an existing backend, reusing the same receiver\nhandler. Demonstrate signed lifecycle ingestion, duplicate delivery, tampering,\nand persisted inbox recovery over real local HTTP. Keep all samples fictional.\n\nAdd composable integration roles: experience, commerce, and data. Ship a short\nAI integration brief and a backend helper that maps Apple/Google OpenIAP\npurchase fields into the installed verification schema. Test invalid inputs\nand exclude client-supplied identity. Keep paywall UI APIs product-specific,\ncurrent IAPKit-only client helpers explicit, and store/device proof separate\nfrom local fixtures. Reuse the existing consumer and contract validators.\n\nThe first bridge test failed because store evidence was nested under an extra\n`evidence` key. Keep the failure output, use the installed input schema's\ntop-level `apple`/`google` members, and rerun that boundary check.\n\nApply the third CLI review. Make the integration brief reachable from the docs\nsite with absolute setup, source, and build-brief links. Accept signed webhook\ndelivery behind a reverse proxy that preserves the public Host header. Keep\nunfinished captures from blocking completed history, retain equal-time fixture\ntransitions, record the observed duplicate response, and configure Yarn's\nnode-modules linker. Preserve the proxy failure and rerun the checks.\n\nPrepare the standalone example for its first public commit. Rewrite the README\naround clone, run, inspect, choose a role, and verify. Put receiver and capture\nsetup in repository documentation so the example works before the docs site is\ndeployed. Preserve every earlier archive. Record this documentation revision,\nverify all source archives again, and export a stable current-source download.\nAdd CI that tests runtime, tooling, archives and the documentation export. This\nrevision is a local publication review, not another completed external review.\n\nFix the first Linux CI failure without rewriting historical recordings. macOS\nAppleDouble metadata must not count as source. Exclude it on extraction, omit it\nfrom new archives, and add a portable extraction regression test. Capture the\ncorrected tooling, verify every source revision, and rerun GitHub CI.\n\nCorrect the final CLI review finding: exercise cancellation at the expiry\nobservation timestamp so the check reaches the expired-state guard. Preserve\nthe earlier capture, record this revision, and verify its archive and patch.\n\nApply the Codex review: authenticate webhook body bytes before UTF-8 decoding.\nReject altered UTF-8 and inserted BOM bytes with an unchanged signature. Reject\nauthentically signed malformed UTF-8 before storage, and accept correctly signed\nUnicode and BOM bodies. Keep the reproduced failure, preserve old checkpoints,\nthen capture and verify the corrected revision.\n", + "sourceHashes": { + ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", + ".yarnrc.yml": "473e6def86fc03638120e0c01d0c8bbab095677256460fa4ea763e5d4697f270", + "AGENTS.md": "38764ea47552533ab5db55f7ade8c0025e2d7af9163715ce89f2d719f5475712", + "BUILD.md": "abad05223f40512522487d83337755d5c3222fda4614e5c2ea92f45d13be977d", + "INTEGRATE.md": "532f25bc83d96350381c8c801caf5837777624095a3f519053133dffdbee1cd8", + "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", + "README.md": "f46856f23a859637f4488074744dbc4f16a7a42999e27491aa832bac2089cbec", + "ai-task.md": "d000a11054750accc6bab8ddbf6d2fc6b674b12c3821ccbfd45729469923b8fe", + "capture.mjs": "4940347c92713f47965400fa9dca0908372ec2b3627612fccf40eb6b636a85a2", + "checkpoint-tools.mjs": "be05f0e7f52b74b180cebb5b39a1a7fbeb7260e90fe4568b1e72eb15bbb2e4c6", + "checkpoint-tools.test.mjs": "9c71b5f95f33fe3352f20836a57d063ed5c680fa0837ae4de399bc820b5d3896", + "checkpoint.json": "a16633b532f348b2d33581c3c57648cc4e53ea027ee01a61f8072230751e4fc0", + "client-bridge.mjs": "a0058579b02c2f3a770c7ff7b59dfe41ce50761361d9a0c7a2df96ab1b6b4acf", + "composition/README.md": "1a35875f1ecd66d48145b0b1acaaefdccec85b8b934a1e593e7d4a0c76525fd8", + "composition/app-backend.mjs": "13d92de6276d27f953c5fc44a8e0857f45ec399dedce4a21ef31a02f6f6c1dee", + "composition/app-backend.test.mjs": "d88286bf5f003ef71c27188fa953df84a528b72cee9959922e43d849be08efcc", + "composition/commerce-client.mjs": "f1f1a056bc76801df25111cb12fdcde501d507a5461ef8b15bfe2f57979cd03c", + "composition/export.mjs": "ccc84dee42d59b1d2b09f16aec2b77a774765dd6fab0f341b01f1043a2e88225", + "composition/memory-provider.mjs": "edf6a7567ead756dc4a5c15d5b5b14f4d4314db31e3aa3c01c8ec543754fcc31", + "composition/purchase-flow.mjs": "ce6f98426c36eab346e900ac06ed31f2d387e542c518a48c92a67f7bae147ec8", + "composition/run.mjs": "f9a3eb73861369f87cc3fe53fb256b5bc21f7fb4724610061eacf68d0b08f191", + "consumer.mjs": "8c1dc8d44eeee630aa2a27b19b7594cc41108b38f6745a503a57c92f2a4f24f7", + "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", + "dashboard.html": "50a19acc0810453fa2ef55f4e5e958b4ba428764fc5a5edc10e262f9c990e532", + "erasure.mjs": "019cb67fba68fe0dee0937db6bcbdec5036090a49672d7769de7ba91d52f7d24", + "export-docs.mjs": "47cf25c7ba1942fb833ac6b496c1e06b4ae06a61f6328330c6a6a4337c164f84", + "package-lock.json": "6f5333cc45203d6a27fec82c9370a8c25fef7faab591467dd2ff3c79acd6ae1c", + "package.json": "cc6a39b0fae3c66c1ef5c7b472d2b67babbc935514cfed4c6304a4c35d078bf4", + "provider.mjs": "e5c498cf01beedab756058f62cbeb5a533477ebb9da1b6064d48f4003e524094", + "scenario.mjs": "b8ff9024afd67701b5f70916a61fa7d538eaec281aa4b5cc660e1c205932a32d", + "server.mjs": "b0f185a3954fbe1d7a4dd15f0dc8638afcf04e386ec730ae17a15860784d9e36", + "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", + "verify-erasure.mjs": "65feaff2e8eb0a3247ecdbc0f511c8eef5e07a4c2c63155b07409ba7cb19be88", + "verify.mjs": "942f1cd3f663dd84350a20fba459f707436a82c691469caf2c2f36155fbd5b72", + "webhooks.mjs": "02db2077e38ca4929537d58be41c13b35d5c8f16cacf829e8ad4b0bded9e322f" + }, + "checks": [ + "Fixture request matches the installed schema", + "Capabilities use the published response schema", + "Purchase storage starts empty", + "No profile conformance is claimed", + "Fixture evidence is accepted", + "Verification does not grant access", + "Invalid evidence produces a negative verdict", + "An upstream outage is not a negative verdict", + "Verification credentials cannot bind a user", + "The server binds Alice", + "Repeating the same binding succeeds", + "Bob cannot take Alice's purchase", + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none", + "Cancellation keeps paid access", + "Cancellation stops renewal", + "Cancellation queues one event", + "A 503 leaves a durable retry", + "Retry survives provider restart", + "Retry keeps the delivery identity", + "Redelivery has one inbox row per event", + "Access closes at expiry before a notification arrives", + "Duplicate store notification emits no extra event", + "Reopening both databases preserves state", + "Receiver deduplication survives restart", + "All four events reach the receiver", + "The final status is inactive", + "Signature vector: single-key", + "Receiver accepts vector: single-key", + "Signature vector: retry-after-backoff", + "Receiver accepts vector: retry-after-backoff", + "Signature vector: raw-utf8-body", + "Receiver accepts vector: raw-utf8-body", + "Signature vector: during-rotation", + "Receiver accepts vector: during-rotation", + "Signature vector: minimal-event-omits-extensions", + "Receiver accepts vector: minimal-event-omits-extensions", + "Receiver rejects: tampered-body", + "Receiver rejects: wrong-secret", + "Receiver rejects: timestamp-outside-tolerance", + "Receiver rejects: timestamp-not-in-signed-material", + "Receiver rejects: retry-reuses-first-signature", + "Receiver rejects: garbage-appended-to-valid-signature", + "Active: before expiry", + "Active: at expiry", + "Active: no deadline", + "InGracePeriod: before expiry", + "InGracePeriod: at expiry", + "InGracePeriod: no deadline", + "InBillingRetry: before expiry", + "InBillingRetry: at expiry", + "InBillingRetry: no deadline", + "Paused: before expiry", + "Paused: at expiry", + "Paused: no deadline", + "Expired: before expiry", + "Expired: at expiry", + "Expired: no deadline", + "Revoked: before expiry", + "Revoked: at expiry", + "Revoked: no deadline", + "Refunded: before expiry", + "Refunded: at expiry", + "Refunded: no deadline", + "Unknown: before expiry", + "Unknown: at expiry", + "Unknown: no deadline", + "FutureState: before expiry", + "FutureState: at expiry", + "FutureState: no deadline", + "Missing credentials are refused", + "Verification role cannot enumerate users", + "Malformed input is refused", + "A real store is not falsely accepted", + "Erasure rejects verification credentials", + "Cancellation after expiry is ignored", + "Expired fixture evidence has an expired verdict", + "Tampered HTTP body has no inbox effect", + "Changed UTF-8 bytes cannot reuse a signature", + "Inserted UTF-8 BOM cannot reuse a signature", + "Authenticated malformed UTF-8 is rejected before storage", + "Authentic Unicode bytes are accepted and stored", + "Authentic BOM bytes are verified before decoding", + "Receiver still has exactly four events", + "Cross-origin demo mutations are refused", + "Overlapping HTTP ownership claims have one winner", + "A cancellation older than the active row is ignored", + "Ignoring an old cancellation preserves renewal", + "Conflicting expiry is not consumed", + "Outbox failure rolls back subscription state", + "Failed transaction leaves the observation retryable", + "Exhausted retries enter dead-letter", + "Grant failure rejects binding", + "Grant failure rolls back ownership", + "An expired purchase can be bound", + "Binding expired evidence emits no grant", + "Delayed binding retains the store occurrence", + "Delayed binding records its actual processing time", + "Late expiry revokes a persisted grant exactly once", + "Repeated expiry emits no second revocation", + "Equal-time expiry retains its lifecycle transition", + "Equal-time observations revoke once and preserve both transitions", + "Health accepts the proxy public Host header", + "subscription.started: authenticated and saved", + "subscription.started: redelivery deduplicated", + "subscription.started: tampering rejected", + "entitlement.granted: authenticated and saved", + "entitlement.granted: redelivery deduplicated", + "entitlement.granted: tampering rejected", + "subscription.renewed: authenticated and saved", + "subscription.renewed: redelivery deduplicated", + "subscription.renewed: tampering rejected", + "subscription.canceled: authenticated and saved", + "subscription.canceled: redelivery deduplicated", + "subscription.canceled: tampering rejected", + "subscription.expired: authenticated and saved", + "subscription.expired: redelivery deduplicated", + "subscription.expired: tampering rejected", + "entitlement.revoked: authenticated and saved", + "entitlement.revoked: redelivery deduplicated", + "entitlement.revoked: tampering rejected", + "subscription.refunded: authenticated and saved", + "subscription.refunded: redelivery deduplicated", + "subscription.refunded: tampering rejected", + "One inbox record per event", + "Inbox survives reopening SQLite", + "apple: maps evidence without forwarding client identity", + "apple: matches the installed verification input schema", + "google: maps evidence without forwarding client identity", + "google: matches the installed verification input schema", + "Rejects missing purchase", + "Rejects unknown store", + "Rejects Amazon needs its own adapter", + "Rejects Horizon needs its own adapter", + "Rejects missing evidence", + "Rejects blank evidence", + "Rejects non-string evidence", + "Rejects oversized evidence", + "An active purchase has a delivered event copy", + "Erasure during delivery completes", + "An in-flight event cannot resurrect receiver data", + "An in-flight acknowledgement cannot resurrect the outbox", + "Repeated erase survives restart", + "An old signed event remains discarded after restart", + "A new event ID cannot bypass erasure", + "Verification cannot bind an erased purchase", + "Stale binding cannot restore an erased account", + "Another account cannot claim erased evidence", + "Erased account is inactive before paid expiry", + "Late lifecycle processing carries no erased identity", + "Persisted protocol records contain no erased user ID", + "Unknown-user erasure is accepted", + "Verification credentials cannot erase users", + "Provider erasure completes", + "Erasure retry after restart returns the same job", + "Purchase has no account identity", + "App event copies are erased", + "Erased account has no access", + "A stale binding retry cannot restore identity" + ], + "history": [ + { + "step": 1, + "title": "Start with the contract", + "built": "HTTP routes + schema validation + SQLite", + "result": "A running server, an empty purchase table, and no access.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "providerCapabilities", + "httpStatus": 200, + "body": { + "specVersion": "1.0", + "implementation": { + "name": "Commerce Protocol Example — fictional fixture store" + }, + "eventTypes": [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked" + ], + "stores": { + "fixture": { + "initialValidation": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "serverNotifications": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "subscriptions": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "renewalEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "refundEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "expiration": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "reconciliation": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "entitlements": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "revenueAmount": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + } + } + } + } + }, + { + "operation": "providerCapabilities", + "httpStatus": 200, + "body": { + "specVersion": "1.0", + "implementation": { + "name": "Commerce Protocol Example — fictional fixture store" + }, + "eventTypes": [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked" + ], + "stores": { + "fixture": { + "initialValidation": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "serverNotifications": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "subscriptions": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "renewalEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "refundEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "expiration": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "reconciliation": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "entitlements": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "revenueAmount": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + } + } + } + } + } + ], + "checks": [ + "Fixture request matches the installed schema", + "Capabilities use the published response schema", + "Purchase storage starts empty", + "No profile conformance is claimed" + ], + "totalChecks": 4 + }, + { + "step": 2, + "title": "Verify a purchase", + "built": "Fixture store adapter + purchase persistence", + "result": "Valid evidence is saved. Alice still has no access.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [ + { + "userId": null, + "productId": "premium.monthly", + "state": "Active", + "willRenew": 1 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "verifyPurchase", + "httpStatus": 200, + "body": { + "store": "fixture", + "isValid": true, + "state": "ENTITLED", + "productId": "premium.monthly", + "environment": "local-fixture" + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "verifyPurchase", + "httpStatus": 200, + "body": { + "store": "fixture", + "isValid": false, + "state": "INAUTHENTIC", + "environment": "local-fixture" + } + }, + { + "operation": "verifyPurchase", + "httpStatus": 502, + "body": { + "error": { + "code": "VERIFICATION_FAILED", + "message": "verification failed" + } + } + } + ], + "checks": [ + "Fixture evidence is accepted", + "Verification does not grant access", + "Invalid evidence produces a negative verdict", + "An upstream outage is not a negative verdict" + ], + "totalChecks": 8 + }, + { + "step": 3, + "title": "Connect it to a user", + "built": "Server authorization + atomic binding + entitlement reads", + "result": "Alice gets Premium. Another user cannot take the purchase.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 1 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": true + } + ] + }, + "deliveries": [ + { + "eventId": "51443cd7-6c99-45ae-8029-2675b18ecff4", + "deliveryId": "3ce6772a-934e-4127-800a-dc8e40b95f19", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + } + ], + "inboxCount": 0, + "responses": [ + { + "operation": "bindPurchase", + "httpStatus": 403, + "body": { + "error": { + "code": "FORBIDDEN", + "message": "forbidden" + } + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": true + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": true + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": false + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": true + } + ] + } + } + ], + "checks": [ + "Verification credentials cannot bind a user", + "The server binds Alice", + "Repeating the same binding succeeds", + "Bob cannot take Alice's purchase", + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none" + ], + "totalChecks": 14 + }, + { + "step": 4, + "title": "Handle cancellation", + "built": "Lifecycle processing + transactional event outbox", + "result": "Renewal stops. Alice keeps the time she already paid for.", + "simulatedTime": "2026-09-08T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + ] + }, + "deliveries": [ + { + "eventId": "51443cd7-6c99-45ae-8029-2675b18ecff4", + "deliveryId": "3ce6772a-934e-4127-800a-dc8e40b95f19", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + }, + { + "eventId": "a81b314a-e3b4-4620-b85b-74a67e7813ba", + "deliveryId": "95a4b394-76dc-4877-be42-b1dca2d9f2a4", + "attempts": 0, + "status": "pending", + "eventType": "subscription.canceled" + } + ], + "inboxCount": 0, + "responses": [ + { + "operation": "subscriptionStatus", + "httpStatus": 200, + "body": { + "active": true, + "subscription": { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + } + } + ], + "checks": [ + "Cancellation keeps paid access", + "Cancellation stops renewal", + "Cancellation queues one event" + ], + "totalChecks": 17 + }, + { + "step": 5, + "title": "Deliver, retry, deduplicate", + "built": "HMAC signatures + retry worker + durable receiver inbox", + "result": "A 503 retries successfully. Redelivery creates no second inbox row.", + "simulatedTime": "2026-09-08T09:00:31.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + ] + }, + "deliveries": [ + { + "eventId": "51443cd7-6c99-45ae-8029-2675b18ecff4", + "deliveryId": "3ce6772a-934e-4127-800a-dc8e40b95f19", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "a81b314a-e3b4-4620-b85b-74a67e7813ba", + "deliveryId": "95a4b394-76dc-4877-be42-b1dca2d9f2a4", + "attempts": 3, + "status": "delivered", + "eventType": "subscription.canceled" + } + ], + "inboxCount": 2, + "responses": [ + { + "operation": "webhook: receiver unavailable", + "body": [ + { + "eventId": "51443cd7-6c99-45ae-8029-2675b18ecff4", + "deliveryId": "3ce6772a-934e-4127-800a-dc8e40b95f19", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + }, + { + "eventId": "a81b314a-e3b4-4620-b85b-74a67e7813ba", + "deliveryId": "95a4b394-76dc-4877-be42-b1dca2d9f2a4", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + } + ] + }, + { + "operation": "webhook: retry after restart", + "body": [ + { + "eventId": "51443cd7-6c99-45ae-8029-2675b18ecff4", + "deliveryId": "3ce6772a-934e-4127-800a-dc8e40b95f19", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + }, + { + "eventId": "a81b314a-e3b4-4620-b85b-74a67e7813ba", + "deliveryId": "95a4b394-76dc-4877-be42-b1dca2d9f2a4", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + } + ] + }, + { + "operation": "webhook: lost-ack redelivery", + "body": [ + { + "eventId": "51443cd7-6c99-45ae-8029-2675b18ecff4", + "deliveryId": "3ce6772a-934e-4127-800a-dc8e40b95f19", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + }, + { + "eventId": "a81b314a-e3b4-4620-b85b-74a67e7813ba", + "deliveryId": "95a4b394-76dc-4877-be42-b1dca2d9f2a4", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + } + ] + } + ], + "checks": [ + "A 503 leaves a durable retry", + "Retry survives provider restart", + "Retry keeps the delivery identity", + "Redelivery has one inbox row per event" + ], + "totalChecks": 21 + }, + { + "step": 6, + "title": "Expire access and restart", + "built": "Expiry-aware reads + recovery from SQLite", + "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", + "simulatedTime": "2026-10-07T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Expired", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [ + { + "eventId": "51443cd7-6c99-45ae-8029-2675b18ecff4", + "deliveryId": "3ce6772a-934e-4127-800a-dc8e40b95f19", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "a81b314a-e3b4-4620-b85b-74a67e7813ba", + "deliveryId": "95a4b394-76dc-4877-be42-b1dca2d9f2a4", + "attempts": 3, + "status": "delivered", + "eventType": "subscription.canceled" + }, + { + "eventId": "cce12b37-1691-4cc9-b183-dbdb122922dd", + "deliveryId": "db3b55ee-49ca-4bf9-a893-9050babebc4d", + "attempts": 1, + "status": "delivered", + "eventType": "subscription.expired" + }, + { + "eventId": "a05f0e33-8764-4085-8a37-d88a36277adf", + "deliveryId": "7445ccbf-b4d7-49c7-b4f7-e799a7ea63ab", + "attempts": 1, + "status": "delivered", + "eventType": "entitlement.revoked" + } + ], + "inboxCount": 4, + "responses": [ + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "webhook: expiry + revocation", + "body": [ + { + "eventId": "cce12b37-1691-4cc9-b183-dbdb122922dd", + "deliveryId": "db3b55ee-49ca-4bf9-a893-9050babebc4d", + "httpStatus": 200, + "attempt": 1, + "status": "delivered" + }, + { + "eventId": "a05f0e33-8764-4085-8a37-d88a36277adf", + "deliveryId": "7445ccbf-b4d7-49c7-b4f7-e799a7ea63ab", + "httpStatus": 200, + "attempt": 1, + "status": "delivered" + } + ] + }, + { + "operation": "subscriptionStatus", + "httpStatus": 200, + "body": { + "active": false, + "subscription": { + "productId": "premium.monthly", + "state": "Expired", + "active": false, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + } + } + ], + "checks": [ + "Access closes at expiry before a notification arrives", + "Duplicate store notification emits no extra event", + "Reopening both databases preserves state", + "Receiver deduplication survives restart", + "All four events reach the receiver", + "The final status is inactive" + ], + "totalChecks": 27 + }, + { + "step": 7, + "title": "Delete the account", + "built": "Idempotent erasure + receiver cleanup + durable deletion guard", + "result": "Alice is removed from purchases and event copies. Late deliveries cannot restore her account data.", + "simulatedTime": "2026-10-07T09:00:00.000Z", + "purchases": [ + { + "userId": null, + "productId": "premium.monthly", + "state": "Expired", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "eraseUser", + "httpStatus": 403, + "body": { + "error": { + "code": "FORBIDDEN", + "message": "forbidden" + } + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "cdd684ad-860e-4aee-9744-e70502090a8e", + "status": "completed" + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "cdd684ad-860e-4aee-9744-e70502090a8e", + "status": "completed" + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": false + } + } + ], + "checks": [ + "Verification credentials cannot erase users", + "Provider erasure completes", + "Erasure retry after restart returns the same job", + "Purchase has no account identity", + "App event copies are erased", + "Erased account has no access", + "A stale binding retry cannot restore identity" + ], + "totalChecks": 34 + } + ], + "screenshot": "screen.png", + "scope": "Incremental implementation of the installed Commerce Protocol contract. HTTP, SQLite and signatures execute locally; store and clock are fixtures. No full profile claim." +} diff --git a/docs/build/07-account-erasure-reviewed/screen.png b/docs/build/07-account-erasure-reviewed/screen.png new file mode 100644 index 0000000..982e657 Binary files /dev/null and b/docs/build/07-account-erasure-reviewed/screen.png differ diff --git a/docs/build/07-account-erasure-reviewed/source.tar.gz b/docs/build/07-account-erasure-reviewed/source.tar.gz new file mode 100644 index 0000000..0d2ccf6 Binary files /dev/null and b/docs/build/07-account-erasure-reviewed/source.tar.gz differ diff --git a/docs/build/07-account-erasure/attempt-1.txt b/docs/build/07-account-erasure/attempt-1.txt new file mode 100644 index 0000000..3ca8e3d --- /dev/null +++ b/docs/build/07-account-erasure/attempt-1.txt @@ -0,0 +1,20 @@ +{ + "startedAt": "2026-09-08T19:22:04.465Z", + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 1s\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && bun test composition/app-backend.test.mjs\n\nCommerce Protocol Example: 159 checks passed. No store or production service contacted.\nbun test v1.3.13 (bf2e2cec)\n\ncomposition/app-backend.test.mjs:\n(pass) account deletion waits for in-flight fulfillment and retries provider erasure after app restart [91.28ms]\n\n 1 pass\n 0 fail\n 6 expect() calls\nRan 1 test across 1 file. [175.00ms]\n" + }, + { + "command": "npm run test:tooling", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [15.55ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [75.72ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.95ms]\n(pass) unfinished captures do not hide completed records [3.34ms]\n\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [121.00ms]\n" + } + ] +} diff --git a/docs/build/07-account-erasure/changes.patch b/docs/build/07-account-erasure/changes.patch new file mode 100644 index 0000000..dc064d7 --- /dev/null +++ b/docs/build/07-account-erasure/changes.patch @@ -0,0 +1,2296 @@ +diff --git a/AGENTS.md b/AGENTS.md +new file mode 100644 +index 0000000..d5632c9 +--- /dev/null ++++ b/AGENTS.md +@@ -0,0 +1,22 @@ ++# Example acceptance ++ ++Read `README.md` for the runnable scope, `INTEGRATE.md` for product boundaries, ++and `BUILD.md` for the implementation milestones and completion checks. ++ ++Before finishing a change: ++ ++- Follow the running example as a first-time reader on desktop and mobile. ++ Explain the result and the reader's next decision before implementation ++ detail. Keep a short record of the tasks attempted and points of confusion. ++- Compare the affected responsibility with the corresponding IAPKit handler ++ and test linked from the [purchase guide](https://openiap.dev/commerce-protocol/getting-started). ++ Report the actual differences; the installed protocol remains authoritative. ++- Replay changed implementation instructions in a clean project, using only ++ the documented inputs. Run the startup command, tests, and affected demos. ++ Retain failed attempts and verify the repaired behavior independently. ++- Identify the tested source revision, commands, results, and fixture scope. ++ Do not infer real-store support, full profile conformance, or interoperability ++ between providers from a local fixture run. Label an AI reader simulation as ++ a simulation, not a human user study. ++ ++Do not manufacture successful output or weaken acceptance to hide a failure. +diff --git a/BUILD.md b/BUILD.md +index b5884a0..94a7142 100644 +--- a/BUILD.md ++++ b/BUILD.md +@@ -32,6 +32,14 @@ Implement the backend in my project. Do not require an OpenIAP or IAPKit checkou + and do not invent request fields, response shapes, role rules, or enum values. + Follow my repository's instructions. Keep work uncommitted for review. + ++Read this example alongside [IAPKit's service source](https://github.com/hyodotdev/openiap/tree/main/packages/kit). ++The [purchase walkthrough](https://openiap.dev/commerce-protocol/getting-started) ++connects each step to both implementations and their checks. Use this example ++to understand the small SQLite flow; use IAPKit to study real store adapters, ++project authorization, account erasure, and the GraphQL adapter. Compare the ++relevant code at each milestone without making either repository a runtime ++dependency of the new project. ++ + ## Start with a reviewable local result + + Use the stack already in my repository. If this is an empty project, choose a +@@ -66,26 +74,46 @@ Build these milestones in order: + events profile requires the public HTTPS destination protections in the spec. + 6. **Recovery:** reopen the databases with pending deliveries, resume processing, + and prove that neither ownership nor receiver deduplication disappears. ++7. **Account deletion:** remove provider identity and recipient copies, retry the ++ same erasure after restart, and reject stale account requests and late events. + + After each milestone, run it. Show the command, actual API result, storage + change, and passing assertions. Capture the working screen. Do not manufacture + logs, screenshots, conformance counts, or claims about capabilities not tested. + ++## Complete the local implementation ++ ++Before calling the result complete, implement erasure for the account lifecycle: ++remove the user identity from provider records and event history, preserve other ++users, and prevent a late retry from restoring erased recipient data. Keep ++provider erasure separate from the recipient's responsibility for delivered ++copies. Exercise erasure during delivery, on repetition, and after restart. ++ ++Run the portable conformance runner for every selected profile and binding. ++Do not finish with tests that expect known conformance failures. Keep every ++previously exercised case in the completed run; changing declarations must not ++hide a failure. Describe fixture-only capabilities explicitly, without implying ++that a real store API or notification channel was connected. ++ ++Copy only source and package metadata into an empty directory. Install, test, ++and start it there, without the development database or output directories. ++Check the visible app after purchase, cancellation, expiry, reload, and deletion. ++ + ## Deliver + + - A runnable local backend and small inspection UI. + - One command that verifies the demonstrated flow and exits nonzero on failure. + - A short visual walkthrough, with real captured results for each milestone. + - The exact scope and remaining work, including real store validation, +- authentication, erasure, multi-tenant isolation, public HTTPS delivery, +- operations, and full profile conformance. Keep the main explanation short; ++ real authentication, multi-tenant isolation, public HTTPS delivery, and ++ operations. Include the passing local conformance report and its fixture scope. Keep the main explanation short; + link the specification for details. + + ## Then extend toward a production provider + + Ask me which real store, backend identity system, and deployment environment to +-integrate before using credentials or external services. Implement the remaining +-operations of each chosen profile, including erasure for `accountLifecycle`. ++integrate before using credentials or external services. Replace fictional store and session adapters with the chosen integrations. ++Keep the completed profile behavior and erasure checks passing. + Run the portable conformance runner for every advertised binding and profile; + also run real store sandbox, recovery, isolation, and load tests. Treat IAPKit as + an implementation example, never as a replacement for the protocol's contract. +diff --git a/INTEGRATE.md b/INTEGRATE.md +index c497c13..ba56c4a 100644 +--- a/INTEGRATE.md ++++ b/INTEGRATE.md +@@ -36,6 +36,13 @@ gives the endpoint, configuration, and limits. + + ## Task for the AI + ++Use the [purchase walkthrough](https://openiap.dev/commerce-protocol/getting-started) ++to compare this example with [IAPKit's service implementation](https://github.com/hyodotdev/openiap/tree/main/packages/kit) ++at each step. This repository shows the small local implementation; IAPKit shows ++store adapters, project credentials, erasure, and both API bindings. Follow the ++linked handlers and checks for the responsibility you own. The installed ++specification defines the required behavior; neither implementation changes it. ++ + Inspect this repository's purchase flow and choose the role from the table. + Install `openiap-commerce-protocol` with this repository's package manager. + Read its `SPEC.md`, generated bindings and schemas, and signature/lifecycle +diff --git a/README.md b/README.md +index ec1ec9a..a11d8e0 100644 +--- a/README.md ++++ b/README.md +@@ -1,8 +1,8 @@ + # OpenIAP Commerce Protocol example + +-A runnable purchase-to-access backend, built and reviewed with AI in six ++A runnable purchase-to-access backend, built and reviewed with AI in seven + milestones. Follow a purchase through verification, ownership, access, and +-signed event delivery. Inspect the actual HTTP responses and database changes. ++signed event delivery, and account deletion. Inspect the actual HTTP responses and database changes. + + The backend uses the published **`openiap-commerce-protocol`** package. HTTP, + SQLite, and webhook signatures run locally; the store, users, and clock are +@@ -23,7 +23,7 @@ npm start + ``` + + Open **http://127.0.0.1:5181**, then click **Run step 1 →** and continue through +-step 6. Each step changes real local state. The dashboard shows purchases, ++step 7. Each step changes real local state. The dashboard shows purchases, + current access, delivery attempts, and expandable request/response details. + No store account, API key, OpenIAP checkout, or IAPKit account is required. + Modern Yarn uses the included `node_modules` linker. +@@ -44,6 +44,7 @@ and the [complete build history](https://github.com/hyodotdev/openiap-commerce-p + | 4. Cancel | Turn off renewal; queue an event | Paid access remains until expiry | + | 5. Deliver | Sign events; retry a failed receiver | A repeated delivery has one inbox effect | + | 6. Expire | Advance the clock; reopen SQLite | Access closes; ownership and delivery records remain | ++| 7. Erase | Remove provider identity and receiver copies | Repeated deletion and late deliveries cannot restore the user | + + Restarting `npm start` creates a fresh temporary database, so you can replay the + walkthrough. Step 6 reopens the existing databases **inside the running process**; +@@ -109,9 +110,32 @@ and Google Chrome. The [recording guide](https://github.com/hyodotdev/openiap-co + explains how to preserve a checkpoint and export evidence. GitHub CI runs the + runtime, tooling, archive, and documentation-export checks. + ++## Account deletion ++ ++Step 7 runs `eraseUser` using server credentials. The provider removes identity ++from purchases and removes identity-bearing event records in one transaction. ++A repeated request returns the same completed job, including after restart. ++ ++The app owns already-delivered copies: it erases its receiver inbox and retains ++a keyed deletion marker so late signed events are acknowledged without storing ++the deleted identity. The example also refuses rebinding erased evidence. ++This is a local ownership policy; the protocol does not cancel the store subscription. ++Database backups and the app’s own account records remain the operator’s responsibility. ++ ++Run `bun verify-erasure.mjs` to exercise deletion while a delivery is in flight, ++late lifecycle events, repeated requests, and storage reopening. ++ ++## Replace the example with IAPKit ++ ++The OpenIAP checkout includes `packages/kit/scripts/docs/run-commerce-interop.mjs`. ++It starts IAPKit with an isolated local Convex deployment and keeps one app ++backend and receiver running while switching the commerce provider. See the ++[composition guide](https://openiap.dev/commerce-protocol/ecosystem#composition-proof) ++for the executed report, source, and command. No IAPKit account or store keys are needed. ++ + ## What remains for production + +-Real store validation and sandbox purchases, login, user erasure, tenant ++Real store validation and sandbox purchases, login, tenant + isolation, GraphQL, public HTTPS delivery protections, and operational recovery + are not implemented here. The backend advertises **no complete profiles**. + Schema checks and the local walkthrough do not establish profile conformance. +diff --git a/capture.mjs b/capture.mjs +index 53904d7..9ff8993 100644 +--- a/capture.mjs ++++ b/capture.mjs +@@ -9,7 +9,7 @@ import { + rmSync, + } from "node:fs"; + import { tmpdir } from "node:os"; +-import { join } from "node:path"; ++import { dirname, join } from "node:path"; + import { pathToFileURL } from "node:url"; + import { chromium } from "@playwright/test"; + import { +@@ -46,8 +46,10 @@ let lab, browser; + try { + mkdirSync(current); + mkdirSync(previous); +- for (const name of SOURCE_FILES) ++ for (const name of SOURCE_FILES) { ++ mkdirSync(dirname(join(current, name)), { recursive: true }); + cpSync(join(root, name), join(current, name)); ++ } + const sourceHashes = hashes(current); + if (checkpoint.previous) { + assert(/^\d\d-[\w-]+$/.test(checkpoint.previous)); +diff --git a/checkpoint-tools.mjs b/checkpoint-tools.mjs +index 06b5f3b..6e6cfd4 100644 +--- a/checkpoint-tools.mjs ++++ b/checkpoint-tools.mjs +@@ -8,6 +8,7 @@ import { + mkdtempSync, + readFileSync, + readdirSync, ++ statSync, + rmSync, + writeFileSync, + } from "node:fs"; +@@ -18,6 +19,7 @@ export const SOURCE_FILES = [ + ".gitignore", + ".yarnrc.yml", + "LICENSE", ++ "AGENTS.md", + "README.md", + "BUILD.md", + "INTEGRATE.md", +@@ -27,6 +29,8 @@ export const SOURCE_FILES = [ + "package-lock.json", + "contract.mjs", + "provider.mjs", ++ "erasure.mjs", ++ "verify-erasure.mjs", + "webhooks.mjs", + "consumer.mjs", + "client-bridge.mjs", +@@ -34,6 +38,15 @@ export const SOURCE_FILES = [ + "server.mjs", + "verify.mjs", + "dashboard.html", ++ "composition/README.md", ++ "composition/app-backend.mjs", ++ "composition/app-backend.test.mjs", ++ "composition/commerce-client.mjs", ++ "composition/export.mjs", ++ "composition/memory-provider.mjs", ++ "composition/purchase-flow.mjs", ++ "composition/run.mjs", ++ + "capture.mjs", + "export-docs.mjs", + "checkpoint-tools.mjs", +@@ -42,12 +55,18 @@ export const SOURCE_FILES = [ + ]; + export const sha256 = (file) => + createHash("sha256").update(readFileSync(file)).digest("hex"); +-export const hashes = (directory) => +- Object.fromEntries( +- readdirSync(directory) +- .sort() +- .map((name) => [name, sha256(join(directory, name))]), +- ); ++export function hashes(directory) { ++ const entries = []; ++ function visit(relative) { ++ for (const name of readdirSync(join(directory, relative)).sort()) { ++ const file = join(relative, name); ++ if (statSync(join(directory, file)).isDirectory()) visit(file); ++ else entries.push([file, sha256(join(directory, file))]); ++ } ++ } ++ visit(""); ++ return Object.fromEntries(entries); ++} + export function sanitize(text) { + const roots = [process.cwd(), import.meta.dir, homedir()] + .filter(Boolean) +diff --git a/checkpoint.json b/checkpoint.json +index 5ee086d..2b4698d 100644 +--- a/checkpoint.json ++++ b/checkpoint.json +@@ -1,8 +1,8 @@ + { +- "step": 6, +- "id": "06-recover-reviewed-8", +- "title": "Expire access and restart", +- "built": "Exact-byte webhook authentication with Unicode regression checks", +- "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", +- "previous": "06-recover-reviewed-7" ++ "step": 7, ++ "id": "07-account-erasure", ++ "title": "Delete the account", ++ "built": "Atomic provider erasure, recipient cleanup and late-delivery guards", ++ "result": "The account identity is removed; retries and restart cannot restore it.", ++ "previous": "06-recover-reviewed-8" + } +diff --git a/composition/README.md b/composition/README.md +new file mode 100644 +index 0000000..00ffd08 +--- /dev/null ++++ b/composition/README.md +@@ -0,0 +1,92 @@ ++# Compose services and inspect the boundary ++ ++Run the same app-backend client against two separately implemented fixture ++providers, and send both providers' events to the same receiver implementation. ++Only connection configuration changes. The runner records the results and source ++hashes, and fails when a compared outcome differs. ++ ++With Bun 1.3.13 and Node.js 24 / npm installed, run from the repository root ++(or the extracted source archive): ++ ++```sh ++npm ci --ignore-scripts ++bun composition/run.mjs ++bun composition/run.mjs --record composition-report.json ++``` ++ ++No environment variables, `.env` file, store accounts, or IAPKit credentials are ++required. The runner creates its fixture credentials, webhook keys, temporary ++databases, and local HTTP ports, then cleans up after itself. Installing ++dependencies needs registry access; the composition run uses loopback HTTP only. ++ ++The existing six-step dashboard remains available through `npm start` at ++`http://127.0.0.1:5181`. Set `COMMERCE_LAB_PORT` only to change that port. ++ ++## Follow the code ++ ++| Part | Source | Responsibility | ++| --------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------- | ++| App backend | `composition/commerce-client.mjs` | Validate calls, verify evidence, bind to the authenticated user, read access | ++| Host/paywall callback | `composition/purchase-flow.mjs` | Purchase → backend fulfillment → finish; display pending, canceled, failed, or fulfilled | ++| Provider A | `provider.mjs` | Existing SQLite purchase and ownership implementation | ++| Provider B | `composition/memory-provider.mjs` | Separate Map-based handlers and event signer; shares contract metadata, no Provider A business logic | ++| Event consumer | `webhooks.mjs` | Authenticate raw bytes and persist one inbox effect per event | ++| Reproduction | `composition/run.mjs` | Start isolated HTTP listeners, run both configurations, compare results and failure cases | ++ ++The app backend holds the server credential and selects `userId` from its own ++authenticated session. The host adapter receives a fulfillment callback; never ++ship `commerce-client.mjs` or its credential to an app. Real SDK pending results ++resume through the app's purchase-update listener. This fixture exercises the ++callback boundary, not a particular paywall SDK or mobile purchase runtime. ++ ++## What the run demonstrates ++ ++Both configurations save verification without granting access, bind only once, ++reject another owner, preserve access after cancellation, and close it exactly ++at expiry even before a notification. Both emit signed events; a 503 retries, ++redelivery has one inbox effect, altered bodies and another emitter's key fail, ++and an optional `extensions["partner.segment"]` string survives storage without editing the ++consumer. The consumer also rejects a malformed successful API response. ++ ++Each emitter/project has its own configured endpoint, secret, and inbox ++database. Equal event IDs from different emitters therefore do not collide. ++This does not identify duplicate real-world facts across a provider cutover. ++ ++## Make and verify one change ++ ++Change the `extensions["partner.segment"]` string in `composition/run.mjs`, rerun ++the command, and inspect its storage check. The purchase flow, client, and ++receiver modules stay the same. For a provider implementation ++change, retain the expected access results and rerun against both providers; ++do not edit the expected outcome merely to make an incompatible result pass. ++ ++`bun composition/export.mjs ` also runs a negative control ++inside a temporary copy: changing the SQLite access deadline from `<` to `<=` ++must fail the deadline check. It leaves the original source unchanged. ++ ++## Bring another implementation ++ ++Use `createCommerceClient({ baseUrl, credential })` for a disposable test account. ++Supply a provider-owned setup/transition adapter in the runner for its fixture ++purchase, cancellation, and expiry; those controls are not protocol operations. ++Keep the client, receiver, and expected outcomes unchanged. Record the exact ++revision, configuration fields changed (never secret values), commands, result ++report, and any required code changes. A changed adapter is evidence of work ++needed, not a reason to hide the change. ++ ++Run the published protocol conformance runner separately for every claimed ++profile/binding. This demo advertises no complete profiles. External teams can ++publish their own report without an OpenIAP account or hosted checker. ++ ++## Scope ++ ++The services communicate over real loopback HTTP in one Bun process. Provider A ++uses SQLite; Provider B keeps state in memory. Store evidence, users, clock, and ++purchase callbacks are fixtures. Both implementations were authored within this ++project: this is reproducible implementation evidence, not independent company ++validation. It proves neither real store verification nor production reliability. ++ ++Both providers start empty. Moving ownership/history, rotating credentials, ++cutover overlap, process-crash recovery, and a real SDK purchase need separate ++tests. The baseline receiver stores events; it does not compute a revenue ledger ++or grant access from webhook arrival. +diff --git a/composition/app-backend.mjs b/composition/app-backend.mjs +new file mode 100644 +index 0000000..04475a7 +--- /dev/null ++++ b/composition/app-backend.mjs +@@ -0,0 +1,114 @@ ++import { Database } from "bun:sqlite"; ++import { createCommerceClient } from "./commerce-client.mjs"; ++import { createErasureLedger } from "../erasure.mjs"; ++ ++// resolveSession is the host app's authentication boundary, supplied by the caller. ++export function startAppBackend({ path, providers, receiver, resolveSession }) { ++ const db = new Database(path, { create: true }); ++ db.exec( ++ "CREATE TABLE IF NOT EXISTS erasure_requests (user_id TEXT, provider TEXT, PRIMARY KEY(user_id, provider))", ++ ); ++ const erased = createErasureLedger(db); ++ const clients = Object.fromEntries( ++ Object.entries(providers).map(([name, config]) => [ ++ name, ++ createCommerceClient(config), ++ ]), ++ ); ++ const inFlight = new Map(); ++ let selected = Object.keys(clients)[0]; ++ async function drainErasure() { ++ for (const row of db.query("SELECT * FROM erasure_requests").all()) { ++ try { ++ receiver.eraseUser(row.user_id); ++ const result = await clients[row.provider].call("eraseUser", { ++ userId: row.user_id, ++ }); ++ if (result.accepted && result.status === "completed") ++ db.query( ++ "DELETE FROM erasure_requests WHERE user_id = ? AND provider = ?", ++ ).run(row.user_id, row.provider); ++ } catch { ++ /* The durable request is retried by the app's worker. */ ++ } ++ } ++ return db.query("SELECT count(*) AS count FROM erasure_requests").get() ++ .count; ++ } ++ const server = Bun.serve({ ++ hostname: "127.0.0.1", ++ port: 0, ++ maxRequestBodySize: 32768, ++ async fetch(request) { ++ const userId = await resolveSession(request); ++ if (!userId || erased.has(userId)) ++ return new Response("Unauthenticated", { status: 401 }); ++ const url = new URL(request.url); ++ try { ++ if (url.pathname === "/purchase" && request.method === "POST") { ++ const input = await request.json(); ++ if (erased.has(userId)) ++ return new Response("Unauthenticated", { status: 401 }); ++ const work = clients[selected].fulfill(input, { ++ userId, ++ productId: "premium.monthly", ++ }); ++ const pending = inFlight.get(userId) ?? new Set(); ++ pending.add(work); ++ inFlight.set(userId, pending); ++ let result; ++ try { ++ result = await work; ++ } finally { ++ pending.delete(work); ++ if (!pending.size) inFlight.delete(userId); ++ } ++ // A deletion can race the upstream calls; never return access afterwards. ++ return Response.json(erased.has(userId) ? { access: false } : result); ++ } ++ if (url.pathname === "/access" && request.method === "GET") { ++ const result = await clients[selected].call("entitlements", { ++ userId, ++ }); ++ return erased.has(userId) ++ ? new Response("Unauthenticated", { status: 401 }) ++ : Response.json(result); ++ } ++ if (url.pathname === "/account" && request.method === "DELETE") { ++ db.transaction(() => { ++ erased.remember(userId); ++ for (const name of Object.keys(clients)) ++ db.query( ++ "INSERT OR IGNORE INTO erasure_requests VALUES (?,?)", ++ ).run(userId, name); ++ })(); ++ receiver.eraseUser(userId); ++ await Promise.allSettled([...(inFlight.get(userId) ?? [])]); ++ const pending = await drainErasure(); ++ return Response.json({ ++ accepted: true, ++ status: pending ? "queued" : "completed", ++ }); ++ } ++ return new Response("Not found", { status: 404 }); ++ } catch { ++ return Response.json( ++ { error: "Commerce provider unavailable; retry the request." }, ++ { status: 503 }, ++ ); ++ } ++ }, ++ }); ++ return { ++ url: `http://127.0.0.1:${server.port}`, ++ select(name) { ++ if (!clients[name]) throw new Error("Unknown provider"); ++ selected = name; ++ }, ++ drainErasure, ++ async close() { ++ await server.stop(true); ++ db.close(); ++ }, ++ }; ++} +diff --git a/composition/app-backend.test.mjs b/composition/app-backend.test.mjs +new file mode 100644 +index 0000000..7f2da7c +--- /dev/null ++++ b/composition/app-backend.test.mjs +@@ -0,0 +1,91 @@ ++import { test, expect } from "bun:test"; ++import { mkdtempSync, rmSync } from "node:fs"; ++import { tmpdir } from "node:os"; ++import { join } from "node:path"; ++import { createProvider, FIXTURE, CREDENTIALS } from "../provider.mjs"; ++import { startConsumer } from "../consumer.mjs"; ++import { startAppBackend } from "./app-backend.mjs"; ++ ++test("account deletion waits for in-flight fulfillment and retries provider erasure after app restart", async () => { ++ const directory = mkdtempSync(join(tmpdir(), "commerce-app-erasure-")); ++ const provider = createProvider( ++ join(directory, "provider.sqlite"), ++ () => FIXTURE.startsAt, ++ ); ++ let releaseBind, signalBind; ++ const binding = new Promise((resolve) => { ++ signalBind = resolve; ++ }); ++ const gate = new Promise((resolve) => { ++ releaseBind = resolve; ++ }); ++ let rejectErase = true; ++ const server = Bun.serve({ ++ hostname: "127.0.0.1", ++ port: 0, ++ async fetch(request) { ++ if (new URL(request.url).pathname.endsWith("/bind")) { ++ signalBind(); ++ await gate; ++ } ++ if (new URL(request.url).pathname.endsWith("/erase") && rejectErase) ++ return new Response(null, { status: 503 }); ++ return provider.fetch(request); ++ }, ++ }); ++ const receiver = startConsumer({ ++ path: join(directory, "receiver.sqlite"), ++ secret: "fixture-key", ++ }); ++ const options = { ++ path: join(directory, "app.sqlite"), ++ providers: { ++ example: { ++ baseUrl: `http://127.0.0.1:${server.port}`, ++ credential: CREDENTIALS.server, ++ }, ++ }, ++ receiver, ++ resolveSession: () => FIXTURE.userId, ++ }; ++ let app = startAppBackend(options); ++ try { ++ const purchase = fetch(app.url + "/purchase", { ++ method: "POST", ++ body: JSON.stringify({ ++ store: FIXTURE.store, ++ evidence: FIXTURE.evidence, ++ }), ++ }); ++ await binding; ++ const deletion = fetch(app.url + "/account", { method: "DELETE" }); ++ for (let i = 0; i < 50; i++) { ++ const response = await fetch(app.url + "/access"); ++ if (response.status === 401) break; ++ await Bun.sleep(10); ++ } ++ expect((await fetch(app.url + "/access")).status).toBe(401); ++ releaseBind(); ++ expect(await (await purchase).json()).toEqual({ access: false }); ++ expect(await (await deletion).json()).toEqual({ ++ accepted: true, ++ status: "queued", ++ }); ++ await app.close(); ++ app = startAppBackend(options); ++ expect( ++ (await fetch(app.url + "/purchase", { method: "POST", body: "{}" })) ++ .status, ++ ).toBe(401); ++ rejectErase = false; ++ expect(await app.drainErasure()).toBe(0); ++ expect(provider.inspect().purchases[0].userId).toBeNull(); ++ } finally { ++ releaseBind(); ++ await app.close(); ++ await receiver.close(); ++ await server.stop(true); ++ provider.close(); ++ rmSync(directory, { recursive: true, force: true }); ++ } ++}); +diff --git a/composition/commerce-client.mjs b/composition/commerce-client.mjs +new file mode 100644 +index 0000000..7b5820f +--- /dev/null ++++ b/composition/commerce-client.mjs +@@ -0,0 +1,46 @@ ++import { operation, validate } from "../contract.mjs"; ++ ++// This module runs on the authenticated app backend, which owns the credential. ++export function createCommerceClient({ baseUrl, credential }) { ++ async function call(name, input) { ++ const spec = operation(name); ++ if (!spec) throw new Error("Unknown operation"); ++ if (spec.input && !validate(spec.input, input)) ++ throw new Error("Invalid operation input"); ++ const url = new URL(spec.path, baseUrl); ++ if (spec.method === "GET" && input) ++ for (const [key, value] of Object.entries(input)) ++ url.searchParams.set(key, value); ++ const response = await fetch(url, { ++ method: spec.method, ++ headers: { ++ "content-type": "application/json", ++ ...(spec.auth === "none" ? {} : { authorization: credential }), ++ }, ++ ...(spec.method === "POST" ? { body: JSON.stringify(input) } : {}), ++ redirect: "error", ++ signal: AbortSignal.timeout(5000), ++ }); ++ const result = await response.json(); ++ if (response.status !== spec.successStatus) ++ throw new Error("Commerce operation failed"); ++ if (!validate(spec.result, result)) ++ throw new Error("Invalid operation result"); ++ return result; ++ } ++ ++ async function fulfill(input, { userId, productId }) { ++ const evidence = { ...input }; ++ delete evidence.userId; ++ const verdict = await call("verifyPurchase", evidence); ++ if (!verdict.isValid) throw new Error("Purchase was not accepted"); ++ const binding = await call("bindPurchase", { ...evidence, userId }); ++ if (!binding.bound) throw new Error("Purchase belongs to another user"); ++ const access = await call("entitlements", { userId }); ++ if (!access.productIds.includes(productId)) ++ throw new Error("Requested product is not accessible"); ++ return access; ++ } ++ ++ return { call, fulfill }; ++} +diff --git a/composition/export.mjs b/composition/export.mjs +new file mode 100644 +index 0000000..cfb9c06 +--- /dev/null ++++ b/composition/export.mjs +@@ -0,0 +1,125 @@ ++import assert from "node:assert/strict"; ++import { spawnSync } from "node:child_process"; ++import { createHash } from "node:crypto"; ++import { ++ cpSync, ++ mkdirSync, ++ mkdtempSync, ++ readFileSync, ++ rmSync, ++ writeFileSync, ++} from "node:fs"; ++import { tmpdir } from "node:os"; ++import { dirname, join, resolve } from "node:path"; ++import { fileURLToPath } from "node:url"; ++import { SOURCE_FILES } from "../checkpoint-tools.mjs"; ++import { COMPOSITION_SOURCES, runComposition } from "./run.mjs"; ++ ++const root = fileURLToPath(new URL("../", import.meta.url)); ++assert( ++ process.argv[2], ++ "Usage: bun composition/export.mjs ", ++); ++const output = resolve(process.argv[2]); ++const temp = mkdtempSync(join(tmpdir(), "commerce-composition-export-")); ++const source = join(temp, "source"), ++ replay = join(temp, "replay"); ++const run = (command, args, cwd) => { ++ const result = spawnSync(command, args, { ++ cwd, ++ encoding: "utf8", ++ maxBuffer: 5 * 1024 * 1024, ++ env: { ...process.env, COPYFILE_DISABLE: "1" }, ++ }); ++ assert.equal( ++ result.status, ++ 0, ++ `${command} failed: ${result.stderr}\n${result.stdout}`, ++ ); ++}; ++try { ++ const report = await runComposition(); ++ const names = [ ++ ...new Set([ ++ ...SOURCE_FILES, ++ ...COMPOSITION_SOURCES, ++ "composition/export.mjs", ++ ]), ++ ].sort(); ++ for (const name of names) { ++ mkdirSync(dirname(join(source, name)), { recursive: true }); ++ cpSync(join(root, name), join(source, name)); ++ } ++ mkdirSync(output, { recursive: true }); ++ const archive = join(output, "source.tar.gz"); ++ run("tar", ["-czf", archive, ...names], source); ++ mkdirSync(replay); ++ run("tar", ["-xzf", archive, "-C", replay], temp); ++ run("npm", ["ci", "--ignore-scripts"], replay); ++ run( ++ process.execPath, ++ ["composition/run.mjs", "--record", "replay.json"], ++ replay, ++ ); ++ const replayed = JSON.parse( ++ readFileSync(join(replay, "replay.json"), "utf8"), ++ ); ++ assert.deepEqual(replayed.sourceHashes, report.sourceHashes); ++ assert.deepEqual(replayed.results, report.results); ++ assert.deepEqual(replayed.checks, report.checks); ++ const providerPath = join(replay, "provider.mjs"); ++ const original = readFileSync(providerPath, "utf8"); ++ assert(original.includes("now < expiresAt")); ++ writeFileSync( ++ providerPath, ++ original.replace("now < expiresAt", "now <= expiresAt"), ++ ); ++ const negative = spawnSync(process.execPath, ["composition/run.mjs"], { ++ cwd: replay, ++ encoding: "utf8", ++ maxBuffer: 1024 * 1024, ++ }); ++ const rejectedCheck = ++ "sqlite: read closes access at the deadline before a notification"; ++ assert.equal(negative.status, 1); ++ assert( ++ (negative.stderr + negative.stdout).includes(rejectedCheck), ++ "Negative control must fail the intended assertion", ++ ); ++ report.negativeControl = { ++ change: ++ "Replace now < expiresAt with now <= expiresAt in a temporary provider copy", ++ detected: true, ++ rejectedCheck, ++ }; ++ report.archiveVerification = { ++ command: "npm ci --ignore-scripts && bun composition/run.mjs", ++ sameSourceAndResults: true, ++ sha256: createHash("sha256").update(readFileSync(archive)).digest("hex"), ++ }; ++ writeFileSync( ++ join(output, "run.json"), ++ JSON.stringify(report, null, 2) + "\n", ++ ); ++ writeFileSync( ++ join(output, "source.json"), ++ JSON.stringify( ++ Object.fromEntries( ++ [ ++ "composition/commerce-client.mjs", ++ "composition/purchase-flow.mjs", ++ "composition/memory-provider.mjs", ++ "composition/run.mjs", ++ ].map((name) => [name, readFileSync(join(source, name), "utf8")]), ++ ), ++ null, ++ 2, ++ ) + "\n", ++ ); ++ cpSync(join(root, "composition/README.md"), join(output, "README.md")); ++ console.log( ++ `Exported ${report.checks.length} composition checks with a clean-install archive replay.`, ++ ); ++} finally { ++ rmSync(temp, { recursive: true, force: true }); ++} +diff --git a/composition/memory-provider.mjs b/composition/memory-provider.mjs +new file mode 100644 +index 0000000..dff6b8a +--- /dev/null ++++ b/composition/memory-provider.mjs +@@ -0,0 +1,266 @@ ++import { createHmac, randomUUID } from "node:crypto"; ++import { ++ COMMERCE_EVENT_VERSION, ++ HTTP_BINDING, ++ WEBHOOK, ++ providerCapabilitiesSchema, ++} from "openiap-commerce-protocol"; ++import { operation, protocolError, validate } from "../contract.mjs"; ++ ++// A second fixture implementation; it shares contract metadata, not SQLite logic. ++export function createMemoryProvider({ fixture, credential, now }) { ++ const purchases = new Map(); ++ const outbox = []; ++ const supported = new Set([ ++ "initialValidation", ++ "subscriptions", ++ "entitlements", ++ "serverNotifications", ++ "expiration", ++ ]); ++ const capabilities = { ++ specVersion: HTTP_BINDING.protocolVersion, ++ implementation: { ++ name: "Memory provider — separately implemented fixture", ++ }, ++ eventTypes: [ ++ "entitlement.granted", ++ "subscription.canceled", ++ "subscription.expired", ++ "entitlement.revoked", ++ ], ++ stores: { ++ [fixture.store]: Object.fromEntries( ++ Object.keys( ++ providerCapabilitiesSchema.$defs.StoreCapabilities.properties, ++ ).map((name) => [ ++ name, ++ { ++ provider: supported.has(name), ++ implementation: supported.has(name), ++ notes: ++ "Fictional store only; no complete profile or production claim.", ++ }, ++ ]), ++ ), ++ }, ++ }; ++ ++ function snapshot(purchase) { ++ return { ++ store: fixture.store, ++ productId: fixture.productId, ++ state: purchase.expired ? "Expired" : "Active", ++ active: !purchase.expired && now() < fixture.expiresAt, ++ expiresAt: fixture.expiresAt, ++ willRenew: purchase.renews, ++ }; ++ } ++ ++ function emit(eventType, purchase) { ++ const event = { ++ eventId: randomUUID(), ++ eventType, ++ eventVersion: COMMERCE_EVENT_VERSION, ++ occurredAt: now(), ++ processedAt: now(), ++ store: fixture.store, ++ environment: "local-fixture", ++ projectId: "memory_example", ++ userId: purchase.owner, ++ productId: fixture.productId, ++ subscription: snapshot(purchase), ++ }; ++ if (!validate("#/$defs/CommerceEvent", event)) ++ throw new Error("Invalid event"); ++ outbox.push({ ++ event, ++ attempts: 0, ++ status: "pending", ++ nextAt: now(), ++ deliveryId: randomUUID(), ++ }); ++ } ++ ++ const handlers = { ++ providerCapabilities: () => capabilities, ++ verifyPurchase(input) { ++ if (input.store !== fixture.store) return { error: "UNSUPPORTED_STORE" }; ++ if (typeof input.evidence !== "string") ++ return { error: "INVALID_REQUEST" }; ++ if (input.evidence === "local-upstream-outage") ++ return { error: "VERIFICATION_FAILED" }; ++ const accepted = input.evidence === fixture.evidence; ++ if (accepted && !purchases.has(input.evidence)) ++ purchases.set(input.evidence, { ++ owner: null, ++ renews: true, ++ expired: false, ++ granted: false, ++ }); ++ return { ++ store: fixture.store, ++ isValid: accepted && now() < fixture.expiresAt, ++ state: !accepted ++ ? "INAUTHENTIC" ++ : now() >= fixture.expiresAt ++ ? "EXPIRED" ++ : "ENTITLED", ++ ...(accepted ? { productId: fixture.productId } : {}), ++ environment: "local-fixture", ++ }; ++ }, ++ bindPurchase(input) { ++ if (input.store !== fixture.store) return { error: "UNSUPPORTED_STORE" }; ++ if (typeof input.evidence !== "string") ++ return { error: "INVALID_REQUEST" }; ++ const purchase = purchases.get(input.evidence); ++ if (!purchase) return { bound: false }; ++ if (purchase.owner === null) { ++ purchase.owner = input.userId; ++ if (snapshot(purchase).active) { ++ emit("entitlement.granted", purchase); ++ purchase.granted = true; ++ } ++ } ++ return { bound: purchase.owner === input.userId }; ++ }, ++ entitlements({ userId }) { ++ const subscriptions = [...purchases.values()] ++ .filter((purchase) => purchase.owner === userId) ++ .map(snapshot) ++ .filter((subscription) => subscription.active); ++ return { ++ userId, ++ productIds: [...new Set(subscriptions.map((row) => row.productId))], ++ subscriptions, ++ }; ++ }, ++ subscriptionStatus({ userId }) { ++ const purchase = [...purchases.values()].find( ++ (row) => row.owner === userId, ++ ); ++ const subscription = purchase ? snapshot(purchase) : undefined; ++ return { ++ active: subscription?.active ?? false, ++ ...(subscription ? { subscription } : {}), ++ }; ++ }, ++ }; ++ ++ async function fetch(request) { ++ const url = new URL(request.url); ++ const spec = HTTP_BINDING.operations.find( ++ (entry) => entry.path === url.pathname && entry.method === request.method, ++ ); ++ if (!spec) return protocolError("NOT_FOUND"); ++ if ( ++ spec.auth !== "none" && ++ request.headers.get("authorization") !== credential ++ ) ++ return protocolError("UNAUTHORIZED"); ++ if (!handlers[spec.name]) return protocolError("UNSUPPORTED_PROFILE"); ++ let input; ++ try { ++ input = spec.input ++ ? request.method === "GET" ++ ? Object.fromEntries(url.searchParams) ++ : await request.json() ++ : null; ++ } catch { ++ return protocolError("INVALID_REQUEST"); ++ } ++ if (spec.input && !validate(spec.input, input)) ++ return protocolError("INVALID_REQUEST"); ++ const result = handlers[spec.name](input); ++ if (result.error) return protocolError(result.error); ++ if (!validate(operation(spec.name).result, result)) ++ return protocolError("INTERNAL_ERROR"); ++ return Response.json(result, { status: spec.successStatus }); ++ } ++ ++ function observe(kind) { ++ const purchase = purchases.get(fixture.evidence); ++ if (!purchase?.owner) throw new Error("Bind the fixture purchase first"); ++ if (kind === "cancel") { ++ if (!purchase.renews) return; ++ purchase.renews = false; ++ emit("subscription.canceled", purchase); ++ } else if (kind === "expire" && now() >= fixture.expiresAt) { ++ if (purchase.expired) return; ++ purchase.expired = true; ++ purchase.renews = false; ++ emit("subscription.expired", purchase); ++ if (purchase.granted) { ++ emit("entitlement.revoked", purchase); ++ purchase.granted = false; ++ } ++ } else throw new Error("Invalid fixture transition"); ++ } ++ ++ function signed(event, secret) { ++ const body = JSON.stringify(event); ++ const timestamp = String(Math.floor(now() / 1000)); ++ const digest = createHmac("sha256", secret) ++ .update(Buffer.concat([Buffer.from(`${timestamp}.`), Buffer.from(body)])) ++ .digest("hex"); ++ return { ++ body, ++ headers: { ++ "content-type": WEBHOOK.contentType, ++ [WEBHOOK.timestampHeader]: timestamp, ++ [WEBHOOK.signatureHeader]: WEBHOOK.signaturePrefix + digest, ++ [WEBHOOK.eventIdHeader]: event.eventId, ++ }, ++ }; ++ } ++ ++ async function flush(url, secret) { ++ const results = []; ++ for (const item of outbox.filter( ++ (row) => row.status === "pending" && row.nextAt <= now(), ++ )) { ++ const request = signed(item.event, secret); ++ let status; ++ try { ++ status = ( ++ await globalThis.fetch(url, { ++ method: "POST", ++ ...request, ++ headers: { ++ ...request.headers, ++ [WEBHOOK.deliveryIdHeader]: item.deliveryId, ++ }, ++ redirect: "error", ++ signal: AbortSignal.timeout(5000), ++ }) ++ ).status; ++ } catch { ++ status = 503; ++ } ++ item.attempts += 1; ++ const retryable = status === 408 || status === 429 || status >= 500; ++ item.status = ++ status >= 200 && status < 300 ++ ? "delivered" ++ : retryable && item.attempts < 3 ++ ? "pending" ++ : "dead-letter"; ++ item.nextAt = now() + 30000 * 2 ** (item.attempts - 1); ++ results.push({ ++ httpStatus: status, ++ status: item.status, ++ attempt: item.attempts, ++ }); ++ } ++ return results; ++ } ++ ++ return { ++ fetch, ++ observe, ++ flush, ++ signed, ++ events: () => outbox.map((row) => row.event), ++ }; ++} +diff --git a/composition/purchase-flow.mjs b/composition/purchase-flow.mjs +new file mode 100644 +index 0000000..2bf375d +--- /dev/null ++++ b/composition/purchase-flow.mjs +@@ -0,0 +1,25 @@ ++// Host callbacks are integration-specific; this is not a protocol paywall API. ++export function createPurchaseFlow({ purchase, fulfill, finish }) { ++ let busy = false; ++ return async function select(productId) { ++ if (busy) return { status: "busy" }; ++ busy = true; ++ try { ++ const result = await purchase(productId); ++ if (result.status === "pending" || result.status === "canceled") ++ return { status: result.status }; ++ if (result.status !== "purchased") throw new Error("Purchase failed"); ++ const access = await fulfill(result.evidence, productId); ++ try { ++ await finish(result); ++ } catch { ++ return { status: "finish-pending", access }; ++ } ++ return { status: "fulfilled", access }; ++ } catch { ++ return { status: "failed" }; ++ } finally { ++ busy = false; ++ } ++ }; ++} +diff --git a/composition/run.mjs b/composition/run.mjs +new file mode 100644 +index 0000000..7fceee5 +--- /dev/null ++++ b/composition/run.mjs +@@ -0,0 +1,587 @@ ++import assert from "node:assert/strict"; ++import { createHash, randomBytes, randomUUID } from "node:crypto"; ++import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; ++import { tmpdir } from "node:os"; ++import { join, resolve } from "node:path"; ++import { Database } from "bun:sqlite"; ++import { WEBHOOK, HTTP_BINDING } from "openiap-commerce-protocol"; ++import { createProvider, FIXTURE, CREDENTIALS } from "../provider.mjs"; ++import { createReceiver, deliver, sign } from "../webhooks.mjs"; ++import { createCommerceClient } from "./commerce-client.mjs"; ++import { createMemoryProvider } from "./memory-provider.mjs"; ++import { createPurchaseFlow } from "./purchase-flow.mjs"; ++ ++export const COMPOSITION_SOURCES = [ ++ "composition/commerce-client.mjs", ++ "composition/memory-provider.mjs", ++ "composition/purchase-flow.mjs", ++ "composition/run.mjs", ++ "composition/README.md", ++ "contract.mjs", ++ "provider.mjs", ++ "webhooks.mjs", ++ "package.json", ++ "package-lock.json", ++]; ++const root = new URL("../", import.meta.url); ++export const sourceHashes = () => ++ Object.fromEntries( ++ COMPOSITION_SOURCES.map((name) => [ ++ name, ++ createHash("sha256") ++ .update(readFileSync(new URL(name, root))) ++ .digest("hex"), ++ ]), ++ ); ++ ++export async function runComposition() { ++ const initialHashes = sourceHashes(); ++ const directory = mkdtempSync(join(tmpdir(), "commerce-composition-")); ++ const servers = [], ++ receivers = []; ++ const checks = [], ++ results = [], ++ traces = []; ++ const check = (name, actual, expected) => { ++ assert.deepEqual(actual, expected, name); ++ checks.push(name); ++ }; ++ let time = FIXTURE.startsAt; ++ const now = () => time; ++ const sqlite = createProvider(join(directory, "provider.sqlite"), now); ++ const memoryCredential = `Bearer fixture-${randomBytes(16).toString("hex")}`; ++ const memory = createMemoryProvider({ ++ fixture: FIXTURE, ++ credential: memoryCredential, ++ now, ++ }); ++ const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence }; ++ const serve = (fetch) => { ++ const server = Bun.serve({ ++ hostname: "127.0.0.1", ++ port: 0, ++ maxRequestBodySize: 65536, ++ fetch, ++ }); ++ servers.push(server); ++ return `http://127.0.0.1:${server.port}`; ++ }; ++ const providers = [ ++ { ++ id: "sqlite", ++ label: "SQLite provider", ++ source: "provider.mjs", ++ fetch: sqlite.fetch, ++ credential: CREDENTIALS.server, ++ observe: (kind) => ++ sqlite.observe({ id: randomUUID(), kind, occurredAt: now() }), ++ events: () => ++ sqlite.db ++ .query("SELECT body FROM outbox ORDER BY rowid") ++ .all() ++ .map((row) => JSON.parse(row.body)), ++ signed: (event, secret) => { ++ const body = JSON.stringify(event), ++ timestamp = String(Math.floor(now() / 1000)); ++ return { ++ body, ++ headers: { ++ "content-type": WEBHOOK.contentType, ++ [WEBHOOK.timestampHeader]: timestamp, ++ [WEBHOOK.signatureHeader]: sign(secret, timestamp, body), ++ [WEBHOOK.eventIdHeader]: event.eventId, ++ }, ++ }; ++ }, ++ flush: (url, secret) => ++ deliver(sqlite, secret, now, (init) => ++ fetch(url, { ++ ...init, ++ redirect: "error", ++ signal: AbortSignal.timeout(5000), ++ }), ++ ), ++ }, ++ { ++ id: "memory", ++ label: "Memory provider", ++ source: "composition/memory-provider.mjs", ++ fetch: memory.fetch, ++ credential: memoryCredential, ++ observe: memory.observe, ++ events: memory.events, ++ signed: memory.signed, ++ flush: memory.flush, ++ }, ++ ]; ++ try { ++ for (const provider of providers) { ++ provider.baseUrl = serve(async (request) => { ++ const response = await provider.fetch(request); ++ traces.push({ ++ provider: provider.id, ++ method: request.method, ++ path: new URL(request.url).pathname, ++ status: response.status, ++ }); ++ return response; ++ }); ++ provider.client = createCommerceClient(provider); ++ provider.secret = randomBytes(32).toString("hex"); ++ provider.inboxPath = join(directory, `${provider.id}-inbox.sqlite`); ++ provider.receiver = createReceiver( ++ provider.inboxPath, ++ provider.secret, ++ now, ++ ); ++ receivers.push(provider.receiver); ++ provider.failures = 1; ++ provider.receiverUrl = serve((request) => { ++ if (provider.failures-- > 0) ++ return new Response("Try again", { status: 503 }); ++ return provider.receiver.fetch(request); ++ }); ++ const descriptor = await provider.client.call("providerCapabilities"); ++ check( ++ `${provider.id}: contract major matches`, ++ descriptor.specVersion.split(".")[0], ++ HTTP_BINDING.protocolVersion.split(".")[0], ++ ); ++ check( ++ `${provider.id}: no unearned profile claim`, ++ descriptor.profiles, ++ undefined, ++ ); ++ const before = await provider.client.call("entitlements", { ++ userId: FIXTURE.userId, ++ }); ++ check( ++ `${provider.id}: no access before verification`, ++ before.productIds, ++ [], ++ ); ++ check( ++ `${provider.id}: rejected evidence stays a verdict`, ++ ( ++ await provider.client.call("verifyPurchase", { ++ ...evidence, ++ evidence: "not-a-purchase", ++ }) ++ ).isValid, ++ false, ++ ); ++ await assert.rejects( ++ () => ++ provider.client.call("verifyPurchase", { ++ ...evidence, ++ evidence: "local-upstream-outage", ++ }), ++ /operation failed/, ++ ); ++ checks.push(`${provider.id}: verifier outage stays an operation failure`); ++ await assert.rejects( ++ () => ++ provider.client.fulfill( ++ { ...evidence, evidence: "not-a-purchase" }, ++ { userId: FIXTURE.userId, productId: FIXTURE.productId }, ++ ), ++ /not accepted/, ++ ); ++ checks.push(`${provider.id}: rejected verification cannot fulfill`); ++ check( ++ `${provider.id}: accepts fixture evidence`, ++ (await provider.client.call("verifyPurchase", evidence)).isValid, ++ true, ++ ); ++ check( ++ `${provider.id}: verification alone grants no access`, ++ (await provider.client.call("entitlements", { userId: FIXTURE.userId })) ++ .productIds, ++ [], ++ ); ++ ++ const order = []; ++ const flow = createPurchaseFlow({ ++ purchase: async (productId) => { ++ check( ++ `${provider.id}: selected product reaches purchase callback`, ++ productId, ++ FIXTURE.productId, ++ ); ++ order.push("purchase"); ++ return { ++ status: "purchased", ++ evidence: { ...evidence, userId: "untrusted_bob" }, ++ }; ++ }, ++ fulfill: async (input, productId) => { ++ order.push("fulfill"); ++ return provider.client.fulfill(input, { ++ userId: FIXTURE.userId, ++ productId, ++ }); ++ }, ++ finish: async () => { ++ order.push("finish"); ++ }, ++ }); ++ check( ++ `${provider.id}: paywall selection completes`, ++ (await flow(FIXTURE.productId)).status, ++ "fulfilled", ++ ); ++ check(`${provider.id}: fulfillment precedes finishing`, order, [ ++ "purchase", ++ "fulfill", ++ "finish", ++ ]); ++ const bound = await provider.client.call("entitlements", { ++ userId: FIXTURE.userId, ++ }); ++ check(`${provider.id}: trusted user receives Premium`, bound.productIds, [ ++ FIXTURE.productId, ++ ]); ++ check( ++ `${provider.id}: client identity is ignored`, ++ ( ++ await provider.client.call("entitlements", { ++ userId: "untrusted_bob", ++ }) ++ ).productIds, ++ [], ++ ); ++ check( ++ `${provider.id}: another user cannot take ownership`, ++ ( ++ await provider.client.call("bindPurchase", { ++ ...evidence, ++ userId: "other_user", ++ }) ++ ).bound, ++ false, ++ ); ++ const again = await provider.client.fulfill(evidence, { ++ userId: FIXTURE.userId, ++ productId: FIXTURE.productId, ++ }); ++ check( ++ `${provider.id}: repeated fulfillment returns the same access`, ++ again, ++ bound, ++ ); ++ provider.observe("cancel"); ++ const canceled = await provider.client.call("subscriptionStatus", { ++ userId: FIXTURE.userId, ++ }); ++ check( ++ `${provider.id}: cancellation preserves paid time`, ++ [canceled.active, canceled.subscription.willRenew], ++ [true, false], ++ ); ++ const firstDelivery = await provider.flush( ++ provider.receiverUrl, ++ provider.secret, ++ ); ++ check( ++ `${provider.id}: temporary receiver failure is queued for retry`, ++ firstDelivery[0].status, ++ "pending", ++ ); ++ provider.result = { ++ id: provider.id, ++ label: provider.label, ++ source: provider.source, ++ before, ++ bound, ++ canceled, ++ }; ++ } ++ ++ time += 30000; ++ for (const provider of providers) { ++ const retries = await provider.flush( ++ provider.receiverUrl, ++ provider.secret, ++ ); ++ check( ++ `${provider.id}: retry succeeds over HTTP`, ++ retries.map((row) => [row.httpStatus, row.attempt]), ++ [[200, 2]], ++ ); ++ check( ++ `${provider.id}: one inbox effect per event`, ++ provider.receiver.count(), ++ 2, ++ ); ++ const event = provider.events()[0]; ++ const request = provider.signed(event, provider.secret); ++ const repeat = await fetch(provider.receiverUrl, { ++ method: "POST", ++ ...request, ++ }); ++ check( ++ `${provider.id}: redelivery is a duplicate`, ++ (await repeat.json()).duplicate, ++ true, ++ ); ++ check( ++ `${provider.id}: modified body fails authentication`, ++ ( ++ await fetch(provider.receiverUrl, { ++ method: "POST", ++ ...request, ++ body: request.body + " ", ++ }) ++ ).status, ++ 401, ++ ); ++ const wrongKey = provider.signed( ++ event, ++ providers.find((other) => other !== provider).secret, ++ ); ++ check( ++ `${provider.id}: another emitter's key is rejected`, ++ (await fetch(provider.receiverUrl, { method: "POST", ...wrongKey })) ++ .status, ++ 401, ++ ); ++ ++ const extension = { ++ ...event, ++ eventId: "same-id-in-each-emitter", ++ extensions: { "partner.segment": "demo" }, ++ }; ++ const extended = provider.signed(extension, provider.secret); ++ check( ++ `${provider.id}: optional extension is accepted`, ++ (await fetch(provider.receiverUrl, { method: "POST", ...extended })) ++ .status, ++ 200, ++ ); ++ check( ++ `${provider.id}: equal IDs in different emitter inboxes are not lost`, ++ provider.receiver.count(), ++ 3, ++ ); ++ const invalidExtension = provider.signed( ++ { ++ ...event, ++ eventId: "invalid-extension", ++ extensions: { partner: { segment: "demo" } }, ++ }, ++ provider.secret, ++ ); ++ check( ++ `${provider.id}: an extension violating the contract is rejected`, ++ ( ++ await fetch(provider.receiverUrl, { ++ method: "POST", ++ ...invalidExtension, ++ }) ++ ).status, ++ 400, ++ ); ++ const inspector = new Database(provider.inboxPath, { readonly: true }); ++ try { ++ const stored = JSON.parse( ++ inspector ++ .query("SELECT body FROM inbox WHERE event_id = ?") ++ .get(extension.eventId).body, ++ ); ++ check( ++ `${provider.id}: extension bytes survive storage`, ++ stored.extensions, ++ extension.extensions, ++ ); ++ } finally { ++ inspector.close(); ++ } ++ } ++ ++ time = FIXTURE.expiresAt; ++ for (const provider of providers) { ++ check( ++ `${provider.id}: read closes access at the deadline before a notification`, ++ (await provider.client.call("entitlements", { userId: FIXTURE.userId })) ++ .productIds, ++ [], ++ ); ++ provider.observe("expire"); ++ provider.result.expired = await provider.client.call( ++ "subscriptionStatus", ++ { userId: FIXTURE.userId }, ++ ); ++ check( ++ `${provider.id}: expired status is inactive`, ++ provider.result.expired.active, ++ false, ++ ); ++ await provider.flush(provider.receiverUrl, provider.secret); ++ check( ++ `${provider.id}: lifecycle and grant events match`, ++ provider.events().map((event) => event.eventType), ++ [ ++ "entitlement.granted", ++ "subscription.canceled", ++ "subscription.expired", ++ "entitlement.revoked", ++ ], ++ ); ++ check( ++ `${provider.id}: all emitted events were persisted`, ++ provider.receiver.count(), ++ 5, ++ ); ++ const wrong = createCommerceClient({ ++ ...provider, ++ credential: "Bearer invalid-fixture-key", ++ }); ++ await assert.rejects( ++ () => wrong.call("entitlements", { userId: FIXTURE.userId }), ++ /operation failed/, ++ ); ++ checks.push(`${provider.id}: wrong caller credentials fail closed`); ++ results.push(provider.result); ++ } ++ for (const state of ["before", "bound", "canceled", "expired"]) ++ check( ++ `same consumer observes equal ${state} results across providers`, ++ results[0][state], ++ results[1][state], ++ ); ++ ++ for (const status of ["pending", "canceled", "failed"]) { ++ let effects = 0; ++ const flow = createPurchaseFlow({ ++ purchase: async () => ({ status }), ++ fulfill: async () => { ++ effects++; ++ }, ++ finish: async () => { ++ effects++; ++ }, ++ }); ++ check( ++ `host: ${status} is shown without fulfillment or finishing`, ++ [(await flow(FIXTURE.productId)).status, effects], ++ [status, 0], ++ ); ++ } ++ let releasePurchase; ++ const blocked = new Promise((resolve) => { ++ releasePurchase = resolve; ++ }); ++ const busyFlow = createPurchaseFlow({ ++ purchase: async () => { ++ await blocked; ++ return { status: "canceled" }; ++ }, ++ fulfill: async () => { ++ throw new Error("Unexpected fulfillment"); ++ }, ++ finish: async () => { ++ throw new Error("Unexpected finish"); ++ }, ++ }); ++ const inProgress = busyFlow(FIXTURE.productId); ++ check( ++ "host: repeated selection does not start another purchase", ++ (await busyFlow(FIXTURE.productId)).status, ++ "busy", ++ ); ++ releasePurchase(); ++ await inProgress; ++ check( ++ "host: selection becomes available after cancellation", ++ (await busyFlow(FIXTURE.productId)).status, ++ "canceled", ++ ); ++ let finished = false; ++ const failedBackend = createPurchaseFlow({ ++ purchase: async () => ({ status: "purchased", evidence }), ++ fulfill: async () => { ++ throw new Error("Backend unavailable"); ++ }, ++ finish: async () => { ++ finished = true; ++ }, ++ }); ++ check( ++ "host: backend failure does not finish a purchase", ++ [(await failedBackend(FIXTURE.productId)).status, finished], ++ ["failed", false], ++ ); ++ const failedFinish = createPurchaseFlow({ ++ purchase: async () => ({ status: "purchased", evidence }), ++ fulfill: async () => ({ productIds: [FIXTURE.productId] }), ++ finish: async () => { ++ throw new Error("Finish unavailable"); ++ }, ++ }); ++ check( ++ "host: a finish failure preserves confirmed access", ++ await failedFinish(FIXTURE.productId), ++ { status: "finish-pending", access: { productIds: [FIXTURE.productId] } }, ++ ); ++ const malformedUrl = serve(() => ++ Response.json({ ++ userId: FIXTURE.userId, ++ productIds: [FIXTURE.productId], ++ }), ++ ); ++ await assert.rejects( ++ () => ++ createCommerceClient({ ++ baseUrl: malformedUrl, ++ credential: "fixture", ++ }).call("entitlements", { userId: FIXTURE.userId }), ++ /Invalid operation result/, ++ ); ++ checks.push("client: a malformed success response is rejected over HTTP"); ++ check( ++ "consumer and provider source files stay unchanged throughout the run", ++ sourceHashes(), ++ initialHashes, ++ ); ++ return { ++ recordedAt: new Date().toISOString(), ++ scope: ++ "Two separately implemented fixture providers and two scoped receivers over loopback HTTP in one Bun process. One backend client and receiver implementation, unchanged across both configurations. Host purchase callbacks are simulated.", ++ limits: [ ++ "Same project authorship; no independent organization validation.", ++ "No store purchase, SDK/device checkout, complete profile conformance, or production deployment.", ++ "SQLite versus in-memory state is tested on fresh stores, not a historical-data migration.", ++ "One trusted emitter/project and secret per receiver database; no cross-provider deduplication claim.", ++ ], ++ protocolVersion: HTTP_BINDING.protocolVersion, ++ sourceHashes: initialHashes, ++ configurationChanges: [ ++ "Provider URL", ++ "Server credential", ++ "Emitter endpoint, signing secret, and isolated inbox database", ++ ], ++ checks, ++ results, ++ traces, ++ }; ++ } finally { ++ await Promise.all(servers.map((server) => server.stop(true))); ++ for (const receiver of receivers) receiver.close(); ++ sqlite.close(); ++ rmSync(directory, { recursive: true, force: true }); ++ } ++} ++ ++if (import.meta.main) { ++ const report = await runComposition(); ++ if (process.argv[2] === "--record") { ++ assert(process.argv[3], "Provide the report filename"); ++ writeFileSync( ++ resolve(process.argv[3]), ++ JSON.stringify(report, null, 2) + "\n", ++ ); ++ } ++ console.log( ++ `Composition: ${report.checks.length} checks passed; same client and receiver source across two fixture providers. No store or production service contacted.`, ++ ); ++} +diff --git a/consumer.mjs b/consumer.mjs +index 972ef32..cb052b8 100644 +--- a/consumer.mjs ++++ b/consumer.mjs +@@ -27,6 +27,8 @@ export function startConsumer({ secret, path, port = 0, now = Date.now }) { + return { + url: `http://127.0.0.1:${server.port}/webhooks/commerce`, + count: () => receiver.count(), ++ eraseUser: (userId) => receiver.eraseUser(userId), ++ inspect: () => receiver.inspect(), + reopen() { + receiver.close(); + receiver = createReceiver(path, secret, now); +diff --git a/dashboard.html b/dashboard.html +index b283719..68bb6ee 100644 +--- a/dashboard.html ++++ b/dashboard.html +@@ -91,7 +91,7 @@ + summary { cursor: pointer; padding: 8px 0; font-weight: 600; } + ol { + display: grid; +- grid-template-columns: repeat(6, 1fr); ++ grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 10px; + padding: 0; + margin: 0 0 24px; +@@ -227,7 +227,7 @@ + Ready to run +

From a purchase to current access.

+

+- Run the six milestones against a new, empty database. ++ Follow a purchase from verification to account deletion in a new, empty database. +

+
+ +diff --git a/erasure.mjs b/erasure.mjs +new file mode 100644 +index 0000000..90164eb +--- /dev/null ++++ b/erasure.mjs +@@ -0,0 +1,36 @@ ++import { createHmac, randomBytes, randomUUID } from "node:crypto"; ++ ++// The random key prevents a database-only reader from guessing short user IDs. ++export function createErasureLedger(db) { ++ db.exec(` ++ PRAGMA secure_delete = ON; ++ CREATE TABLE IF NOT EXISTS erasure_key (id INTEGER PRIMARY KEY CHECK (id = 1), value TEXT NOT NULL); ++ CREATE TABLE IF NOT EXISTS erased_users (user_hash TEXT PRIMARY KEY, job_id TEXT NOT NULL); ++ `); ++ db.query("INSERT OR IGNORE INTO erasure_key VALUES (1, ?)").run( ++ randomBytes(32).toString("hex"), ++ ); ++ const key = db ++ .query("SELECT value FROM erasure_key WHERE id = 1") ++ .get().value; ++ const hash = (userId) => ++ createHmac("sha256", key).update(userId).digest("hex"); ++ return { ++ has: (userId) => ++ Boolean( ++ db ++ .query("SELECT 1 FROM erased_users WHERE user_hash = ?") ++ .get(hash(userId)), ++ ), ++ remember(userId) { ++ const userHash = hash(userId); ++ db.query("INSERT OR IGNORE INTO erased_users VALUES (?, ?)").run( ++ userHash, ++ randomUUID(), ++ ); ++ return db ++ .query("SELECT job_id FROM erased_users WHERE user_hash = ?") ++ .get(userHash).job_id; ++ }, ++ }; ++} +diff --git a/export-docs.mjs b/export-docs.mjs +index f7da37e..4dbddd0 100644 +--- a/export-docs.mjs ++++ b/export-docs.mjs +@@ -29,8 +29,15 @@ const selected = guide.map((step) => { + return record; + }); + const last = selected.at(-1); +-assert.deepEqual(selected.map((record) => record.step), [1, 2, 3, 4, 5, 6]); +-assert(guide.every((step) => typeof step.label === "string" && step.label.length > 0)); ++assert.deepEqual( ++ selected.map((record) => record.step), ++ guide.map((_, index) => index + 1), ++); ++assert( ++ guide.every( ++ (step) => typeof step.label === "string" && step.label.length > 0, ++ ), ++); + assert.deepEqual( + Object.fromEntries( + SOURCE_FILES.sort().map((name) => [ +diff --git a/package.json b/package.json +index 36f1f41..d920e9e 100644 +--- a/package.json ++++ b/package.json +@@ -5,7 +5,7 @@ + "type": "module", + "scripts": { + "start": "bun server.mjs", +- "test": "bun verify.mjs", ++ "test": "bun verify.mjs && bun test composition/app-backend.test.mjs", + "capture": "bun capture.mjs", + "verify:checkpoints": "bun verify-checkpoints.mjs", + "test:tooling": "bun test checkpoint-tools.test.mjs", +diff --git a/provider.mjs b/provider.mjs +index 643c03c..37c5b19 100644 +--- a/provider.mjs ++++ b/provider.mjs +@@ -6,6 +6,7 @@ import { + HTTP_BINDING, + } from "openiap-commerce-protocol"; + import { protocolError, validate } from "./contract.mjs"; ++import { createErasureLedger } from "./erasure.mjs"; + + export const FIXTURE = Object.freeze({ + store: "fixture", +@@ -29,16 +30,23 @@ export function isEntitled(state, expiresAt, now) { + } + + // This adapter recognizes one fictional purchase; it never contacts a store. +-function verifyFixture(input) { +- if (input.store !== FIXTURE.store) return { error: "UNSUPPORTED_STORE" }; +- if (typeof input.evidence !== "string") return { error: "INVALID_REQUEST" }; +- if (input.evidence === "local-upstream-outage") { ++function fixtureEvidence(input) { ++ return input.store === "google" ++ ? input.google?.purchaseToken ++ : input.evidence; ++} ++ ++function verifyFixture(input, fixture) { ++ if (input.store !== fixture.store) return { error: "UNSUPPORTED_STORE" }; ++ const evidence = fixtureEvidence(input); ++ if (typeof evidence !== "string") return { error: "INVALID_REQUEST" }; ++ if (evidence === "local-upstream-outage") { + return { error: "VERIFICATION_FAILED" }; + } +- return { accepted: input.evidence === FIXTURE.evidence }; ++ return { accepted: evidence === fixture.evidence }; + } + +-export function createProvider(path, now) { ++export function createProvider(path, now, fixture = FIXTURE) { + const db = new Database(path, { create: true }); + db.exec(` + PRAGMA journal_mode = WAL; +@@ -54,13 +62,23 @@ export function createProvider(path, now) { + next_at INTEGER NOT NULL DEFAULT 0 + ); + `); ++ const erasures = createErasureLedger(db); ++ if ( ++ !db ++ .query("PRAGMA table_info(purchases)") ++ .all() ++ .some((column) => column.name === "erased") ++ ) ++ db.exec( ++ "ALTER TABLE purchases ADD COLUMN erased INTEGER NOT NULL DEFAULT 0", ++ ); + + function snapshot(row) { + return { + productId: row.product_id, + state: row.state, + active: isEntitled(row.state, row.expires_at, now()), +- store: FIXTURE.store, ++ store: fixture.store, + expiresAt: row.expires_at, + willRenew: Boolean(row.will_renew), + }; +@@ -104,7 +122,7 @@ export function createProvider(path, now) { + }, + eventTypes, + stores: { +- fixture: Object.fromEntries( ++ [fixture.store]: Object.fromEntries( + capabilityNames.map((key) => [ + key, + { +@@ -125,7 +143,7 @@ export function createProvider(path, now) { + eventVersion: COMMERCE_EVENT_VERSION, + occurredAt, + processedAt: now(), +- store: FIXTURE.store, ++ store: fixture.store, + environment: "local-fixture", + projectId: "commerce_example", + productId: row.product_id, +@@ -143,39 +161,40 @@ export function createProvider(path, now) { + const handlers = { + providerCapabilities: () => capabilities, + verifyPurchase(input) { +- const verdict = verifyFixture(input); ++ const verdict = verifyFixture(input, fixture); + if (verdict.error) return verdict; + if (verdict.accepted) { + db.query( + `INSERT OR IGNORE INTO purchases (fingerprint, user_id, product_id, state, expires_at, will_renew, observed_at) VALUES (?, NULL, ?, 'Active', ?, 1, ?)`, + ).run( +- fingerprint(input.evidence), +- FIXTURE.productId, +- FIXTURE.expiresAt, +- FIXTURE.startsAt, ++ fingerprint(fixtureEvidence(input)), ++ fixture.productId, ++ fixture.expiresAt, ++ fixture.startsAt, + ); + } + return { +- store: FIXTURE.store, +- isValid: verdict.accepted && now() < FIXTURE.expiresAt, ++ store: fixture.store, ++ isValid: verdict.accepted && now() < fixture.expiresAt, + state: !verdict.accepted + ? "INAUTHENTIC" +- : now() >= FIXTURE.expiresAt ++ : now() >= fixture.expiresAt + ? "EXPIRED" + : "ENTITLED", +- ...(verdict.accepted ? { productId: FIXTURE.productId } : {}), ++ ...(verdict.accepted ? { productId: fixture.productId } : {}), + environment: "local-fixture", + }; + }, + bindPurchase(input) { +- if (input.store !== FIXTURE.store) return { error: "UNSUPPORTED_STORE" }; +- if (typeof input.evidence !== "string") ++ if (input.store !== fixture.store) return { error: "UNSUPPORTED_STORE" }; ++ if (typeof fixtureEvidence(input) !== "string") + return { error: "INVALID_REQUEST" }; + return db.transaction(() => { +- const key = fingerprint(input.evidence); ++ if (erasures.has(input.userId)) return { bound: false }; ++ const key = fingerprint(fixtureEvidence(input)); + const updated = db + .query( +- "UPDATE purchases SET user_id = ? WHERE fingerprint = ? AND user_id IS NULL", ++ "UPDATE purchases SET user_id = ? WHERE fingerprint = ? AND user_id IS NULL AND erased = 0", + ) + .run(input.userId, key); + const row = db +@@ -191,6 +210,19 @@ export function createProvider(path, now) { + })(); + }, + entitlements: (input) => entitlements(input.userId), ++ eraseUser(input) { ++ return db.transaction(() => { ++ const jobId = erasures.remember(input.userId); ++ db.query( ++ "UPDATE purchases SET user_id = NULL, erased = 1, entitlement_granted = 0 WHERE user_id = ?", ++ ).run(input.userId); ++ // A claimed delivery may already be in flight; the receiver erases its own copy. ++ db.query( ++ "DELETE FROM outbox WHERE json_extract(body, '$.userId') = ?", ++ ).run(input.userId); ++ return { accepted: true, jobId, status: "completed" }; ++ })(); ++ }, + subscriptionStatus(input) { + const snapshots = rowsFor(input.userId).map(snapshot); + const subscription = snapshots.find((row) => row.active) ?? snapshots[0]; +@@ -251,7 +283,7 @@ export function createProvider(path, now) { + return false; + const row = db + .query("SELECT * FROM purchases WHERE fingerprint = ?") +- .get(fingerprint(FIXTURE.evidence)); ++ .get(fingerprint(fixture.evidence)); + if (!row) throw new Error("Verify the fixture purchase first"); + if (kind === "expire" && occurredAt < row.expires_at) { + throw new Error("Premature expiry requires store reconciliation"); +@@ -295,7 +327,7 @@ export function createProvider(path, now) { + "SELECT user_id AS userId, product_id AS productId, state, will_renew AS willRenew FROM purchases", + ) + .all(), +- access: entitlements(FIXTURE.userId), ++ access: entitlements(fixture.userId), + deliveries: db + .query( + "SELECT event_id AS eventId, delivery_id AS deliveryId, attempts, status, body FROM outbox ORDER BY rowid", +diff --git a/scenario.mjs b/scenario.mjs +index 525327c..492dc3c 100644 +--- a/scenario.mjs ++++ b/scenario.mjs +@@ -36,6 +36,12 @@ export const STAGES = [ + result: + "Access closes at the deadline. Restarting preserves purchases and deliveries.", + }, ++ { ++ title: "Delete the account", ++ built: "Idempotent erasure + receiver cleanup + durable deletion guard", ++ result: ++ "Alice is removed from purchases and event copies. Late deliveries cannot restore her account data.", ++ }, + ]; + + export async function requestOperation(baseUrl, name, input, role = "server") { +@@ -293,6 +299,44 @@ export function createScenario(runtime) { + .active, + false, + ); ++ } else if (stage === 6) { ++ check( ++ "Verification credentials cannot erase users", ++ (await run("eraseUser", { userId: FIXTURE.userId }, "verification")) ++ .httpStatus, ++ 403, ++ ); ++ // The app removes its event copies before requesting provider erasure. ++ runtime.receiver.eraseUser(FIXTURE.userId); ++ const erased = await run("eraseUser", { userId: FIXTURE.userId }); ++ check( ++ "Provider erasure completes", ++ [erased.body.accepted, erased.body.status], ++ [true, "completed"], ++ ); ++ runtime.restart(); ++ check( ++ "Erasure retry after restart returns the same job", ++ (await run("eraseUser", { userId: FIXTURE.userId })).body, ++ erased.body, ++ ); ++ check( ++ "Purchase has no account identity", ++ runtime.provider.inspect().purchases.map((row) => row.userId), ++ [null], ++ ); ++ check("App event copies are erased", runtime.receiver.count(), 0); ++ check( ++ "Erased account has no access", ++ (await run("entitlements", { userId: FIXTURE.userId })).body.productIds, ++ [], ++ ); ++ check( ++ "A stale binding retry cannot restore identity", ++ (await run("bindPurchase", { ...evidence, userId: FIXTURE.userId })) ++ .body.bound, ++ false, ++ ); + } + const entry = { + step: stage + 1, +diff --git a/verify-erasure.mjs b/verify-erasure.mjs +new file mode 100644 +index 0000000..e3e9d7e +--- /dev/null ++++ b/verify-erasure.mjs +@@ -0,0 +1,153 @@ ++import assert from "node:assert/strict"; ++import { rmSync } from "node:fs"; ++import { startLab } from "./server.mjs"; ++import { FIXTURE } from "./provider.mjs"; ++import { requestOperation, STAGES } from "./scenario.mjs"; ++import { deliver, sign } from "./webhooks.mjs"; ++import { WEBHOOK } from "openiap-commerce-protocol"; ++ ++export async function verifyErasure() { ++ const checks = []; ++ const check = (name, actual, expected) => { ++ assert.deepEqual(actual, expected, name); ++ checks.push(name); ++ }; ++ const lab = startLab(); ++ try { ++ const { runtime } = lab; ++ const call = (name, input, role) => ++ requestOperation(runtime.baseUrl, name, input, role); ++ const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence }; ++ await call("verifyPurchase", evidence); ++ await call("bindPurchase", { ...evidence, userId: FIXTURE.userId }); ++ const pending = runtime.provider.db ++ .query("SELECT body FROM outbox LIMIT 1") ++ .get().body; ++ const post = (body) => { ++ const timestamp = String(Math.floor(runtime.now() / 1000)); ++ return runtime.post({ ++ method: "POST", ++ body, ++ headers: { ++ [WEBHOOK.timestampHeader]: timestamp, ++ [WEBHOOK.signatureHeader]: sign(runtime.secret, timestamp, body), ++ [WEBHOOK.eventIdHeader]: JSON.parse(body).eventId, ++ }, ++ }); ++ }; ++ await post(pending); ++ check( ++ "An active purchase has a delivered event copy", ++ runtime.receiver.count(), ++ 1, ++ ); ++ runtime.receiver.eraseUser(FIXTURE.userId); ++ let erased; ++ await deliver( ++ runtime.provider, ++ runtime.secret, ++ runtime.now, ++ async (init) => { ++ erased = await call("eraseUser", { userId: FIXTURE.userId }); ++ return runtime.post(init); ++ }, ++ ); ++ check("Erasure during delivery completes", erased.body.status, "completed"); ++ check( ++ "An in-flight event cannot resurrect receiver data", ++ runtime.receiver.count(), ++ 0, ++ ); ++ check( ++ "An in-flight acknowledgement cannot resurrect the outbox", ++ runtime.provider.inspect().deliveries, ++ [], ++ ); ++ runtime.restart(); ++ check( ++ "Repeated erase survives restart", ++ (await call("eraseUser", { userId: FIXTURE.userId })).body, ++ erased.body, ++ ); ++ check( ++ "An old signed event remains discarded after restart", ++ (await (await post(pending)).json()).discarded, ++ "erased-user", ++ ); ++ const late = JSON.stringify({ ++ ...JSON.parse(pending), ++ eventId: "late-new-event", ++ }); ++ check( ++ "A new event ID cannot bypass erasure", ++ (await (await post(late)).json()).discarded, ++ "erased-user", ++ ); ++ check( ++ "Verification cannot bind an erased purchase", ++ (await call("verifyPurchase", evidence)).body.isValid, ++ true, ++ ); ++ check( ++ "Stale binding cannot restore an erased account", ++ (await call("bindPurchase", { ...evidence, userId: FIXTURE.userId })).body ++ .bound, ++ false, ++ ); ++ check( ++ "Another account cannot claim erased evidence", ++ (await call("bindPurchase", { ...evidence, userId: "demo_bob" })).body ++ .bound, ++ false, ++ ); ++ check( ++ "Erased account is inactive before paid expiry", ++ (await call("subscriptionStatus", { userId: FIXTURE.userId })).body, ++ { active: false }, ++ ); ++ runtime.time = FIXTURE.expiresAt; ++ runtime.provider.observe({ ++ id: "expiry-after-deletion", ++ kind: "expire", ++ occurredAt: runtime.time, ++ }); ++ await deliver(runtime.provider, runtime.secret, runtime.now, runtime.post); ++ check( ++ "Late lifecycle processing carries no erased identity", ++ runtime.receiver.inspect().map((event) => event.userId), ++ [undefined], ++ ); ++ const serialized = JSON.stringify([ ++ runtime.provider.db.query("SELECT * FROM purchases").all(), ++ runtime.provider.db.query("SELECT * FROM outbox").all(), ++ runtime.provider.db.query("SELECT * FROM erased_users").all(), ++ runtime.receiver.inspect(), ++ ]); ++ check( ++ "Persisted protocol records contain no erased user ID", ++ serialized.includes(FIXTURE.userId), ++ false, ++ ); ++ check( ++ "Unknown-user erasure is accepted", ++ (await call("eraseUser", { userId: "missing_user" })).body.accepted, ++ true, ++ ); ++ } finally { ++ await lab.close(); ++ rmSync(lab.directory, { recursive: true, force: true }); ++ } ++ const walkthrough = startLab(); ++ try { ++ for (let i = 0; i < STAGES.length; i++) ++ await walkthrough.scenario.advance(); ++ checks.push(...walkthrough.scenario.history.at(-1).checks); ++ } finally { ++ await walkthrough.close(); ++ rmSync(walkthrough.directory, { recursive: true, force: true }); ++ } ++ return checks; ++} ++ ++if (import.meta.main) ++ console.log(`${(await verifyErasure()).length} erasure checks passed.`); +diff --git a/verify.mjs b/verify.mjs +index 0c0f246..060c6f2 100644 +--- a/verify.mjs ++++ b/verify.mjs +@@ -8,6 +8,7 @@ import { runConsumerDemo } from "./consumer.mjs"; + import { runBridgeDemo } from "./client-bridge.mjs"; + import { startLab } from "./server.mjs"; + import { authentic, createReceiver, deliver, sign } from "./webhooks.mjs"; ++import { verifyErasure } from "./verify-erasure.mjs"; + + export async function verifyLab({ compareSigner } = {}) { + const lab = startLab(); +@@ -96,9 +97,10 @@ export async function verifyLab({ compareSigner } = {}) { + "UNSUPPORTED_STORE", + ); + check( +- "Erasure is explicitly unimplemented", +- (await call("eraseUser", { userId: FIXTURE.userId })).body.error.code, +- "UNSUPPORTED_PROFILE", ++ "Erasure rejects verification credentials", ++ (await call("eraseUser", { userId: FIXTURE.userId }, "verification")) ++ .httpStatus, ++ 403, + ); + check( + "Cancellation after expiry is ignored", +@@ -425,6 +427,7 @@ export async function verifyLab({ compareSigner } = {}) { + } + checks.push(...(await runConsumerDemo()).checks); + checks.push(...runBridgeDemo()); ++ checks.push(...(await verifyErasure())); + return checks; + } + +diff --git a/webhooks.mjs b/webhooks.mjs +index d7035a2..d8e18c4 100644 +--- a/webhooks.mjs ++++ b/webhooks.mjs +@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite"; + import { createHmac, timingSafeEqual } from "node:crypto"; + import { WEBHOOK } from "openiap-commerce-protocol"; + import { validate } from "./contract.mjs"; ++import { createErasureLedger } from "./erasure.mjs"; + + export function sign(secret, timestamp, body) { + return ( +@@ -37,6 +38,7 @@ export function createReceiver(path, secret, now) { + db.exec( + "CREATE TABLE IF NOT EXISTS inbox (event_id TEXT PRIMARY KEY, body TEXT NOT NULL)", + ); ++ const erasures = createErasureLedger(db); + + async function fetch(request) { + const bytes = new Uint8Array(await request.arrayBuffer()); +@@ -62,6 +64,8 @@ export function createReceiver(path, secret, now) { + return new Response("Invalid event", { status: 400 }); + if (request.headers.get(WEBHOOK.eventIdHeader) !== event.eventId) + return new Response("Event ID mismatch", { status: 400 }); ++ if (event.userId && erasures.has(event.userId)) ++ return Response.json({ accepted: true, discarded: "erased-user" }); + // Inbox insertion is the durable effect; downstream jobs can consume it later. + const result = db + .query("INSERT OR IGNORE INTO inbox VALUES (?, ?)") +@@ -71,6 +75,19 @@ export function createReceiver(path, secret, now) { + + return { + fetch, ++ eraseUser(userId) { ++ return db.transaction(() => { ++ erasures.remember(userId); ++ return db ++ .query("DELETE FROM inbox WHERE json_extract(body, '$.userId') = ?") ++ .run(userId).changes; ++ })(); ++ }, ++ inspect: () => ++ db ++ .query("SELECT body FROM inbox ORDER BY rowid") ++ .all() ++ .map((row) => JSON.parse(row.body)), + count: () => db.query("SELECT count(*) AS count FROM inbox").get().count, + close: () => db.close(), + }; +@@ -84,7 +101,11 @@ export async function deliver(provider, secret, now, post) { + "SELECT * FROM outbox WHERE status = 'pending' AND next_at <= ? ORDER BY rowid", + ) + .all(now()); +- for (const row of rows) { ++ for (const candidate of rows) { ++ const row = provider.db ++ .query("SELECT * FROM outbox WHERE event_id = ? AND status = 'pending'") ++ .get(candidate.event_id); ++ if (!row) continue; + const timestamp = Math.floor(now() / 1000).toString(); + const headers = { + "content-type": WEBHOOK.contentType, diff --git a/docs/build/07-account-erasure/mobile.png b/docs/build/07-account-erasure/mobile.png new file mode 100644 index 0000000..5bca953 Binary files /dev/null and b/docs/build/07-account-erasure/mobile.png differ diff --git a/docs/build/07-account-erasure/run.json b/docs/build/07-account-erasure/run.json new file mode 100644 index 0000000..e9545e0 --- /dev/null +++ b/docs/build/07-account-erasure/run.json @@ -0,0 +1,910 @@ +{ + "step": 7, + "id": "07-account-erasure", + "title": "Delete the account", + "built": "Atomic provider erasure, recipient cleanup and late-delivery guards", + "result": "The account identity is removed; retries and restart cannot restore it.", + "previous": "06-recover-reviewed-8", + "startedAt": "2026-09-08T19:22:04.465Z", + "recordedAt": "2026-09-08T19:22:12.430Z", + "packageVersion": "0.1.0", + "task": "# Review the completed backend\n\nApply the Fable 5.1 max CLI review to the final checkpoint. Add the missing\nfirst-binding grant event in the same transaction as ownership, reject a\npremature expiry without consuming its observation, and verify both rollback\nand expiry boundaries. Correct test labels to describe what they exercise.\n\nRepair patch generation without rewriting historical source or screenshots.\nBuild patches from the preceding archived source and verify their hashes.\nInstall and test each archive outside the monorepo with npm. Capture the revised\nfinal screen on desktop and mobile, then export only matching source evidence.\n\nKeep the original six checkpoints as history. Explain their incomplete discovery\nand missing grant behavior; do not describe them as conformant providers. Keep\nall changes uncommitted for maintainer review.\n\nApply the second review: retain gate delivery state for delayed expiry,\nretain store occurrence on delayed binding, preserve consecutive failure logs,\nand state actual package contents and runtime requirements. Add a ready-to-run\ngeneric event receiver for an existing backend, reusing the same receiver\nhandler. Demonstrate signed lifecycle ingestion, duplicate delivery, tampering,\nand persisted inbox recovery over real local HTTP. Keep all samples fictional.\n\nAdd composable integration roles: experience, commerce, and data. Ship a short\nAI integration brief and a backend helper that maps Apple/Google OpenIAP\npurchase fields into the installed verification schema. Test invalid inputs\nand exclude client-supplied identity. Keep paywall UI APIs product-specific,\ncurrent IAPKit-only client helpers explicit, and store/device proof separate\nfrom local fixtures. Reuse the existing consumer and contract validators.\n\nThe first bridge test failed because store evidence was nested under an extra\n`evidence` key. Keep the failure output, use the installed input schema's\ntop-level `apple`/`google` members, and rerun that boundary check.\n\nApply the third CLI review. Make the integration brief reachable from the docs\nsite with absolute setup, source, and build-brief links. Accept signed webhook\ndelivery behind a reverse proxy that preserves the public Host header. Keep\nunfinished captures from blocking completed history, retain equal-time fixture\ntransitions, record the observed duplicate response, and configure Yarn's\nnode-modules linker. Preserve the proxy failure and rerun the checks.\n\nPrepare the standalone example for its first public commit. Rewrite the README\naround clone, run, inspect, choose a role, and verify. Put receiver and capture\nsetup in repository documentation so the example works before the docs site is\ndeployed. Preserve every earlier archive. Record this documentation revision,\nverify all source archives again, and export a stable current-source download.\nAdd CI that tests runtime, tooling, archives and the documentation export. This\nrevision is a local publication review, not another completed external review.\n\nFix the first Linux CI failure without rewriting historical recordings. macOS\nAppleDouble metadata must not count as source. Exclude it on extraction, omit it\nfrom new archives, and add a portable extraction regression test. Capture the\ncorrected tooling, verify every source revision, and rerun GitHub CI.\n\nCorrect the final CLI review finding: exercise cancellation at the expiry\nobservation timestamp so the check reaches the expired-state guard. Preserve\nthe earlier capture, record this revision, and verify its archive and patch.\n\nApply the Codex review: authenticate webhook body bytes before UTF-8 decoding.\nReject altered UTF-8 and inserted BOM bytes with an unchanged signature. Reject\nauthentically signed malformed UTF-8 before storage, and accept correctly signed\nUnicode and BOM bodies. Keep the reproduced failure, preserve old checkpoints,\nthen capture and verify the corrected revision.\n", + "sourceHashes": { + ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", + ".yarnrc.yml": "473e6def86fc03638120e0c01d0c8bbab095677256460fa4ea763e5d4697f270", + "AGENTS.md": "38764ea47552533ab5db55f7ade8c0025e2d7af9163715ce89f2d719f5475712", + "BUILD.md": "abad05223f40512522487d83337755d5c3222fda4614e5c2ea92f45d13be977d", + "INTEGRATE.md": "532f25bc83d96350381c8c801caf5837777624095a3f519053133dffdbee1cd8", + "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", + "README.md": "f46856f23a859637f4488074744dbc4f16a7a42999e27491aa832bac2089cbec", + "ai-task.md": "d000a11054750accc6bab8ddbf6d2fc6b674b12c3821ccbfd45729469923b8fe", + "capture.mjs": "4940347c92713f47965400fa9dca0908372ec2b3627612fccf40eb6b636a85a2", + "checkpoint-tools.mjs": "be05f0e7f52b74b180cebb5b39a1a7fbeb7260e90fe4568b1e72eb15bbb2e4c6", + "checkpoint-tools.test.mjs": "9c71b5f95f33fe3352f20836a57d063ed5c680fa0837ae4de399bc820b5d3896", + "checkpoint.json": "1bdbc58dd1baa4032b5004760863c46580de384042d4190de5937aee070f9d7f", + "client-bridge.mjs": "a0058579b02c2f3a770c7ff7b59dfe41ce50761361d9a0c7a2df96ab1b6b4acf", + "composition/README.md": "1a35875f1ecd66d48145b0b1acaaefdccec85b8b934a1e593e7d4a0c76525fd8", + "composition/app-backend.mjs": "13d92de6276d27f953c5fc44a8e0857f45ec399dedce4a21ef31a02f6f6c1dee", + "composition/app-backend.test.mjs": "d88286bf5f003ef71c27188fa953df84a528b72cee9959922e43d849be08efcc", + "composition/commerce-client.mjs": "f1f1a056bc76801df25111cb12fdcde501d507a5461ef8b15bfe2f57979cd03c", + "composition/export.mjs": "ccc84dee42d59b1d2b09f16aec2b77a774765dd6fab0f341b01f1043a2e88225", + "composition/memory-provider.mjs": "edf6a7567ead756dc4a5c15d5b5b14f4d4314db31e3aa3c01c8ec543754fcc31", + "composition/purchase-flow.mjs": "ce6f98426c36eab346e900ac06ed31f2d387e542c518a48c92a67f7bae147ec8", + "composition/run.mjs": "f9a3eb73861369f87cc3fe53fb256b5bc21f7fb4724610061eacf68d0b08f191", + "consumer.mjs": "8c1dc8d44eeee630aa2a27b19b7594cc41108b38f6745a503a57c92f2a4f24f7", + "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", + "dashboard.html": "7a3e5f09ff7758af42c63bf1ea1d972bcb00193d3c276618d224df7abc7c5674", + "erasure.mjs": "019cb67fba68fe0dee0937db6bcbdec5036090a49672d7769de7ba91d52f7d24", + "export-docs.mjs": "47cf25c7ba1942fb833ac6b496c1e06b4ae06a61f6328330c6a6a4337c164f84", + "package-lock.json": "6f5333cc45203d6a27fec82c9370a8c25fef7faab591467dd2ff3c79acd6ae1c", + "package.json": "cc6a39b0fae3c66c1ef5c7b472d2b67babbc935514cfed4c6304a4c35d078bf4", + "provider.mjs": "e5c498cf01beedab756058f62cbeb5a533477ebb9da1b6064d48f4003e524094", + "scenario.mjs": "b8ff9024afd67701b5f70916a61fa7d538eaec281aa4b5cc660e1c205932a32d", + "server.mjs": "b0f185a3954fbe1d7a4dd15f0dc8638afcf04e386ec730ae17a15860784d9e36", + "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", + "verify-erasure.mjs": "65feaff2e8eb0a3247ecdbc0f511c8eef5e07a4c2c63155b07409ba7cb19be88", + "verify.mjs": "942f1cd3f663dd84350a20fba459f707436a82c691469caf2c2f36155fbd5b72", + "webhooks.mjs": "02db2077e38ca4929537d58be41c13b35d5c8f16cacf829e8ad4b0bded9e322f" + }, + "checks": [ + "Fixture request matches the installed schema", + "Capabilities use the published response schema", + "Purchase storage starts empty", + "No profile conformance is claimed", + "Fixture evidence is accepted", + "Verification does not grant access", + "Invalid evidence produces a negative verdict", + "An upstream outage is not a negative verdict", + "Verification credentials cannot bind a user", + "The server binds Alice", + "Repeating the same binding succeeds", + "Bob cannot take Alice's purchase", + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none", + "Cancellation keeps paid access", + "Cancellation stops renewal", + "Cancellation queues one event", + "A 503 leaves a durable retry", + "Retry survives provider restart", + "Retry keeps the delivery identity", + "Redelivery has one inbox row per event", + "Access closes at expiry before a notification arrives", + "Duplicate store notification emits no extra event", + "Reopening both databases preserves state", + "Receiver deduplication survives restart", + "All four events reach the receiver", + "The final status is inactive", + "Signature vector: single-key", + "Receiver accepts vector: single-key", + "Signature vector: retry-after-backoff", + "Receiver accepts vector: retry-after-backoff", + "Signature vector: raw-utf8-body", + "Receiver accepts vector: raw-utf8-body", + "Signature vector: during-rotation", + "Receiver accepts vector: during-rotation", + "Signature vector: minimal-event-omits-extensions", + "Receiver accepts vector: minimal-event-omits-extensions", + "Receiver rejects: tampered-body", + "Receiver rejects: wrong-secret", + "Receiver rejects: timestamp-outside-tolerance", + "Receiver rejects: timestamp-not-in-signed-material", + "Receiver rejects: retry-reuses-first-signature", + "Receiver rejects: garbage-appended-to-valid-signature", + "Active: before expiry", + "Active: at expiry", + "Active: no deadline", + "InGracePeriod: before expiry", + "InGracePeriod: at expiry", + "InGracePeriod: no deadline", + "InBillingRetry: before expiry", + "InBillingRetry: at expiry", + "InBillingRetry: no deadline", + "Paused: before expiry", + "Paused: at expiry", + "Paused: no deadline", + "Expired: before expiry", + "Expired: at expiry", + "Expired: no deadline", + "Revoked: before expiry", + "Revoked: at expiry", + "Revoked: no deadline", + "Refunded: before expiry", + "Refunded: at expiry", + "Refunded: no deadline", + "Unknown: before expiry", + "Unknown: at expiry", + "Unknown: no deadline", + "FutureState: before expiry", + "FutureState: at expiry", + "FutureState: no deadline", + "Missing credentials are refused", + "Verification role cannot enumerate users", + "Malformed input is refused", + "A real store is not falsely accepted", + "Erasure rejects verification credentials", + "Cancellation after expiry is ignored", + "Expired fixture evidence has an expired verdict", + "Tampered HTTP body has no inbox effect", + "Changed UTF-8 bytes cannot reuse a signature", + "Inserted UTF-8 BOM cannot reuse a signature", + "Authenticated malformed UTF-8 is rejected before storage", + "Authentic Unicode bytes are accepted and stored", + "Authentic BOM bytes are verified before decoding", + "Receiver still has exactly four events", + "Cross-origin demo mutations are refused", + "Overlapping HTTP ownership claims have one winner", + "A cancellation older than the active row is ignored", + "Ignoring an old cancellation preserves renewal", + "Conflicting expiry is not consumed", + "Outbox failure rolls back subscription state", + "Failed transaction leaves the observation retryable", + "Exhausted retries enter dead-letter", + "Grant failure rejects binding", + "Grant failure rolls back ownership", + "An expired purchase can be bound", + "Binding expired evidence emits no grant", + "Delayed binding retains the store occurrence", + "Delayed binding records its actual processing time", + "Late expiry revokes a persisted grant exactly once", + "Repeated expiry emits no second revocation", + "Equal-time expiry retains its lifecycle transition", + "Equal-time observations revoke once and preserve both transitions", + "Health accepts the proxy public Host header", + "subscription.started: authenticated and saved", + "subscription.started: redelivery deduplicated", + "subscription.started: tampering rejected", + "entitlement.granted: authenticated and saved", + "entitlement.granted: redelivery deduplicated", + "entitlement.granted: tampering rejected", + "subscription.renewed: authenticated and saved", + "subscription.renewed: redelivery deduplicated", + "subscription.renewed: tampering rejected", + "subscription.canceled: authenticated and saved", + "subscription.canceled: redelivery deduplicated", + "subscription.canceled: tampering rejected", + "subscription.expired: authenticated and saved", + "subscription.expired: redelivery deduplicated", + "subscription.expired: tampering rejected", + "entitlement.revoked: authenticated and saved", + "entitlement.revoked: redelivery deduplicated", + "entitlement.revoked: tampering rejected", + "subscription.refunded: authenticated and saved", + "subscription.refunded: redelivery deduplicated", + "subscription.refunded: tampering rejected", + "One inbox record per event", + "Inbox survives reopening SQLite", + "apple: maps evidence without forwarding client identity", + "apple: matches the installed verification input schema", + "google: maps evidence without forwarding client identity", + "google: matches the installed verification input schema", + "Rejects missing purchase", + "Rejects unknown store", + "Rejects Amazon needs its own adapter", + "Rejects Horizon needs its own adapter", + "Rejects missing evidence", + "Rejects blank evidence", + "Rejects non-string evidence", + "Rejects oversized evidence", + "An active purchase has a delivered event copy", + "Erasure during delivery completes", + "An in-flight event cannot resurrect receiver data", + "An in-flight acknowledgement cannot resurrect the outbox", + "Repeated erase survives restart", + "An old signed event remains discarded after restart", + "A new event ID cannot bypass erasure", + "Verification cannot bind an erased purchase", + "Stale binding cannot restore an erased account", + "Another account cannot claim erased evidence", + "Erased account is inactive before paid expiry", + "Late lifecycle processing carries no erased identity", + "Persisted protocol records contain no erased user ID", + "Unknown-user erasure is accepted", + "Verification credentials cannot erase users", + "Provider erasure completes", + "Erasure retry after restart returns the same job", + "Purchase has no account identity", + "App event copies are erased", + "Erased account has no access", + "A stale binding retry cannot restore identity" + ], + "history": [ + { + "step": 1, + "title": "Start with the contract", + "built": "HTTP routes + schema validation + SQLite", + "result": "A running server, an empty purchase table, and no access.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "providerCapabilities", + "httpStatus": 200, + "body": { + "specVersion": "1.0", + "implementation": { + "name": "Commerce Protocol Example — fictional fixture store" + }, + "eventTypes": [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked" + ], + "stores": { + "fixture": { + "initialValidation": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "serverNotifications": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "subscriptions": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "renewalEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "refundEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "expiration": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "reconciliation": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "entitlements": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "revenueAmount": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + } + } + } + } + }, + { + "operation": "providerCapabilities", + "httpStatus": 200, + "body": { + "specVersion": "1.0", + "implementation": { + "name": "Commerce Protocol Example — fictional fixture store" + }, + "eventTypes": [ + "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked" + ], + "stores": { + "fixture": { + "initialValidation": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "serverNotifications": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "subscriptions": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "renewalEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "refundEvents": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "expiration": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "reconciliation": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "entitlements": { + "provider": true, + "implementation": true, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + }, + "revenueAmount": { + "provider": false, + "implementation": false, + "notes": "Local fixture demonstration only; no real store integration or profile conformance claim." + } + } + } + } + } + ], + "checks": [ + "Fixture request matches the installed schema", + "Capabilities use the published response schema", + "Purchase storage starts empty", + "No profile conformance is claimed" + ], + "totalChecks": 4 + }, + { + "step": 2, + "title": "Verify a purchase", + "built": "Fixture store adapter + purchase persistence", + "result": "Valid evidence is saved. Alice still has no access.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [ + { + "userId": null, + "productId": "premium.monthly", + "state": "Active", + "willRenew": 1 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "verifyPurchase", + "httpStatus": 200, + "body": { + "store": "fixture", + "isValid": true, + "state": "ENTITLED", + "productId": "premium.monthly", + "environment": "local-fixture" + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "verifyPurchase", + "httpStatus": 200, + "body": { + "store": "fixture", + "isValid": false, + "state": "INAUTHENTIC", + "environment": "local-fixture" + } + }, + { + "operation": "verifyPurchase", + "httpStatus": 502, + "body": { + "error": { + "code": "VERIFICATION_FAILED", + "message": "verification failed" + } + } + } + ], + "checks": [ + "Fixture evidence is accepted", + "Verification does not grant access", + "Invalid evidence produces a negative verdict", + "An upstream outage is not a negative verdict" + ], + "totalChecks": 8 + }, + { + "step": 3, + "title": "Connect it to a user", + "built": "Server authorization + atomic binding + entitlement reads", + "result": "Alice gets Premium. Another user cannot take the purchase.", + "simulatedTime": "2026-09-07T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 1 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": true + } + ] + }, + "deliveries": [ + { + "eventId": "f430ce42-5989-4aea-85ff-c8529b6245ad", + "deliveryId": "5f641cce-2aa4-4fcc-8afb-e5e30b60120d", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + } + ], + "inboxCount": 0, + "responses": [ + { + "operation": "bindPurchase", + "httpStatus": 403, + "body": { + "error": { + "code": "FORBIDDEN", + "message": "forbidden" + } + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": true + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": true + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": false + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": true + } + ] + } + } + ], + "checks": [ + "Verification credentials cannot bind a user", + "The server binds Alice", + "Repeating the same binding succeeds", + "Bob cannot take Alice's purchase", + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none" + ], + "totalChecks": 14 + }, + { + "step": 4, + "title": "Handle cancellation", + "built": "Lifecycle processing + transactional event outbox", + "result": "Renewal stops. Alice keeps the time she already paid for.", + "simulatedTime": "2026-09-08T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + ] + }, + "deliveries": [ + { + "eventId": "f430ce42-5989-4aea-85ff-c8529b6245ad", + "deliveryId": "5f641cce-2aa4-4fcc-8afb-e5e30b60120d", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + }, + { + "eventId": "5a8247ae-b80d-4e39-b079-e7de56e50105", + "deliveryId": "a749f2ea-c1a3-400e-88d8-bdc925f09b4f", + "attempts": 0, + "status": "pending", + "eventType": "subscription.canceled" + } + ], + "inboxCount": 0, + "responses": [ + { + "operation": "subscriptionStatus", + "httpStatus": 200, + "body": { + "active": true, + "subscription": { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + } + } + ], + "checks": [ + "Cancellation keeps paid access", + "Cancellation stops renewal", + "Cancellation queues one event" + ], + "totalChecks": 17 + }, + { + "step": 5, + "title": "Deliver, retry, deduplicate", + "built": "HMAC signatures + retry worker + durable receiver inbox", + "result": "A 503 retries successfully. Redelivery creates no second inbox row.", + "simulatedTime": "2026-09-08T09:00:31.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Active", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [ + "premium.monthly" + ], + "subscriptions": [ + { + "productId": "premium.monthly", + "state": "Active", + "active": true, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + ] + }, + "deliveries": [ + { + "eventId": "f430ce42-5989-4aea-85ff-c8529b6245ad", + "deliveryId": "5f641cce-2aa4-4fcc-8afb-e5e30b60120d", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "5a8247ae-b80d-4e39-b079-e7de56e50105", + "deliveryId": "a749f2ea-c1a3-400e-88d8-bdc925f09b4f", + "attempts": 3, + "status": "delivered", + "eventType": "subscription.canceled" + } + ], + "inboxCount": 2, + "responses": [ + { + "operation": "webhook: receiver unavailable", + "body": [ + { + "eventId": "f430ce42-5989-4aea-85ff-c8529b6245ad", + "deliveryId": "5f641cce-2aa4-4fcc-8afb-e5e30b60120d", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + }, + { + "eventId": "5a8247ae-b80d-4e39-b079-e7de56e50105", + "deliveryId": "a749f2ea-c1a3-400e-88d8-bdc925f09b4f", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + } + ] + }, + { + "operation": "webhook: retry after restart", + "body": [ + { + "eventId": "f430ce42-5989-4aea-85ff-c8529b6245ad", + "deliveryId": "5f641cce-2aa4-4fcc-8afb-e5e30b60120d", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + }, + { + "eventId": "5a8247ae-b80d-4e39-b079-e7de56e50105", + "deliveryId": "a749f2ea-c1a3-400e-88d8-bdc925f09b4f", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + } + ] + }, + { + "operation": "webhook: lost-ack redelivery", + "body": [ + { + "eventId": "f430ce42-5989-4aea-85ff-c8529b6245ad", + "deliveryId": "5f641cce-2aa4-4fcc-8afb-e5e30b60120d", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + }, + { + "eventId": "5a8247ae-b80d-4e39-b079-e7de56e50105", + "deliveryId": "a749f2ea-c1a3-400e-88d8-bdc925f09b4f", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + } + ] + } + ], + "checks": [ + "A 503 leaves a durable retry", + "Retry survives provider restart", + "Retry keeps the delivery identity", + "Redelivery has one inbox row per event" + ], + "totalChecks": 21 + }, + { + "step": 6, + "title": "Expire access and restart", + "built": "Expiry-aware reads + recovery from SQLite", + "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", + "simulatedTime": "2026-10-07T09:00:00.000Z", + "purchases": [ + { + "userId": "demo_alice", + "productId": "premium.monthly", + "state": "Expired", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [ + { + "eventId": "f430ce42-5989-4aea-85ff-c8529b6245ad", + "deliveryId": "5f641cce-2aa4-4fcc-8afb-e5e30b60120d", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "5a8247ae-b80d-4e39-b079-e7de56e50105", + "deliveryId": "a749f2ea-c1a3-400e-88d8-bdc925f09b4f", + "attempts": 3, + "status": "delivered", + "eventType": "subscription.canceled" + }, + { + "eventId": "f7803d1b-c81c-495b-b477-b24e17c6aa21", + "deliveryId": "6e0bb01e-185c-4d63-9016-c02dd7be282d", + "attempts": 1, + "status": "delivered", + "eventType": "subscription.expired" + }, + { + "eventId": "d1fb27e7-5a07-4cd9-9ddf-0740d4c02c5f", + "deliveryId": "96e6c878-88de-4177-82cc-9018e69dc9b5", + "attempts": 1, + "status": "delivered", + "eventType": "entitlement.revoked" + } + ], + "inboxCount": 4, + "responses": [ + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "webhook: expiry + revocation", + "body": [ + { + "eventId": "f7803d1b-c81c-495b-b477-b24e17c6aa21", + "deliveryId": "6e0bb01e-185c-4d63-9016-c02dd7be282d", + "httpStatus": 200, + "attempt": 1, + "status": "delivered" + }, + { + "eventId": "d1fb27e7-5a07-4cd9-9ddf-0740d4c02c5f", + "deliveryId": "96e6c878-88de-4177-82cc-9018e69dc9b5", + "httpStatus": 200, + "attempt": 1, + "status": "delivered" + } + ] + }, + { + "operation": "subscriptionStatus", + "httpStatus": 200, + "body": { + "active": false, + "subscription": { + "productId": "premium.monthly", + "state": "Expired", + "active": false, + "store": "fixture", + "expiresAt": 1791363600000, + "willRenew": false + } + } + } + ], + "checks": [ + "Access closes at expiry before a notification arrives", + "Duplicate store notification emits no extra event", + "Reopening both databases preserves state", + "Receiver deduplication survives restart", + "All four events reach the receiver", + "The final status is inactive" + ], + "totalChecks": 27 + }, + { + "step": 7, + "title": "Delete the account", + "built": "Idempotent erasure + receiver cleanup + durable deletion guard", + "result": "Alice is removed from purchases and event copies. Late deliveries cannot restore her account data.", + "simulatedTime": "2026-10-07T09:00:00.000Z", + "purchases": [ + { + "userId": null, + "productId": "premium.monthly", + "state": "Expired", + "willRenew": 0 + } + ], + "access": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + }, + "deliveries": [], + "inboxCount": 0, + "responses": [ + { + "operation": "eraseUser", + "httpStatus": 403, + "body": { + "error": { + "code": "FORBIDDEN", + "message": "forbidden" + } + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "179e6eac-1acd-4a80-bf2d-cba9ad65bebf", + "status": "completed" + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "179e6eac-1acd-4a80-bf2d-cba9ad65bebf", + "status": "completed" + } + }, + { + "operation": "entitlements", + "httpStatus": 200, + "body": { + "userId": "demo_alice", + "productIds": [], + "subscriptions": [] + } + }, + { + "operation": "bindPurchase", + "httpStatus": 200, + "body": { + "bound": false + } + } + ], + "checks": [ + "Verification credentials cannot erase users", + "Provider erasure completes", + "Erasure retry after restart returns the same job", + "Purchase has no account identity", + "App event copies are erased", + "Erased account has no access", + "A stale binding retry cannot restore identity" + ], + "totalChecks": 34 + } + ], + "screenshot": "screen.png", + "scope": "Incremental implementation of the installed Commerce Protocol contract. HTTP, SQLite and signatures execute locally; store and clock are fixtures. No full profile claim." +} diff --git a/docs/build/07-account-erasure/screen.png b/docs/build/07-account-erasure/screen.png new file mode 100644 index 0000000..9959620 Binary files /dev/null and b/docs/build/07-account-erasure/screen.png differ diff --git a/docs/build/07-account-erasure/source.tar.gz b/docs/build/07-account-erasure/source.tar.gz new file mode 100644 index 0000000..4bdd9ca Binary files /dev/null and b/docs/build/07-account-erasure/source.tar.gz differ diff --git a/docs/build/README.md b/docs/build/README.md index 20b31da..6ed1c2d 100644 --- a/docs/build/README.md +++ b/docs/build/README.md @@ -3,8 +3,8 @@ Give AI a small job, run the result, and inspect what changed. If a check or the screen is wrong, fix it and repeat that check before adding the next feature. -This example grew through six executable source checkpoints. Each folder holds -its AI task, source hashes, actual HTTP results, and verification in `run.json`; +This example grew through seven executable milestones and their review revisions. +Each folder holds its AI task, source hashes, actual HTTP results, and verification in `run.json`; `source.tar.gz` runs independently and `changes.patch` shows the added code. | Step | Ask AI to build | What the run demonstrates | @@ -15,6 +15,7 @@ its AI task, source hashes, actual HTTP results, and verification in `run.json`; | [4. Cancellation](04-cancel/run.json) | Stop renewal and queue the event atomically | Alice keeps paid access. Discovery can now advertise an event the implementation actually emits. | | [5. Delivery](05-deliver/run.json) | Sign, retry, and deduplicate | A failed delivery retries after reopening storage. A repeated delivery has one inbox effect. | | [6. Reviewed recovery](06-recover-reviewed-8/run.json) | Enforce expiry, check persistence, and map client evidence | The reviewed final version adds atomic binding grants, rejects conflicting expiry, closes access at the deadline, and preserves storage on reopening. | +| [7. Account deletion](07-account-erasure-interoperable-6/run.json) | Erase provider identity and delivered event copies | Repeated erasure, late events, in-flight fulfillment, and reopened storage cannot restore the account. | ## What review changed @@ -43,7 +44,9 @@ from an earlier internal prototype replaced by this example. No live store purch production-provider conformance is demonstrated. The original [step 6](06-recover/run.json) is retained before the reviewed final -revision. Apply patches in folder order, including that intermediate version. +revision. Apply patches in predecessor order: follow each record's `previous` +link back to the first checkpoint, then apply that chain from oldest to newest. +Include intermediate review revisions; folder names do not determine the order. [verification.json](verification.json) records a fresh extraction, source hash comparison, patch application, and npm test for every archived revision. diff --git a/docs/build/consumer-run.json b/docs/build/consumer-run.json index 428988b..743788f 100644 --- a/docs/build/consumer-run.json +++ b/docs/build/consumer-run.json @@ -1,5 +1,5 @@ { - "recordedAt": "2026-09-07T18:19:57.523Z", + "recordedAt": "2026-09-08T22:51:58.185Z", "scope": "Real loopback HTTP, signature validation and durable deduplication. Fictional lifecycle samples; no provider or store contacted. One emitter/project per receiver database. No business or revenue calculation.", "checks": [ "Health accepts the proxy public Host header", @@ -30,11 +30,11 @@ "results": [ { "event": { - "eventId": "9d14a7f3-78ff-4009-9cdf-954fdc34c916", + "eventId": "8848b69f-ed2d-417f-832b-bbdc92b7d572", "eventType": "subscription.started", "eventVersion": "1.0", - "occurredAt": 1788805137493, - "processedAt": 1788805197493, + "occurredAt": 1788907858142, + "processedAt": 1788907918142, "store": "fixture", "environment": "local-fixture", "projectId": "demo_project", @@ -49,7 +49,7 @@ "productId": "premium.monthly", "state": "Active", "active": true, - "expiresAt": 1788805257493, + "expiresAt": 1788907978142, "willRenew": true } }, @@ -60,11 +60,11 @@ }, { "event": { - "eventId": "14fefd1e-e3fc-42a6-bd64-90cea4544dd0", + "eventId": "10462632-3097-4a0e-9b9c-1b122814d84a", "eventType": "entitlement.granted", "eventVersion": "1.0", - "occurredAt": 1788805138493, - "processedAt": 1788805197493, + "occurredAt": 1788907859142, + "processedAt": 1788907918142, "store": "fixture", "environment": "local-fixture", "projectId": "demo_project", @@ -74,7 +74,7 @@ "productId": "premium.monthly", "state": "Active", "active": true, - "expiresAt": 1788805257493, + "expiresAt": 1788907978142, "willRenew": true } }, @@ -85,11 +85,11 @@ }, { "event": { - "eventId": "ef5407dd-fb63-499a-a70b-d416e3df0179", + "eventId": "db2d47db-4e58-46c3-b56c-d0a6d744c95e", "eventType": "subscription.renewed", "eventVersion": "1.0", - "occurredAt": 1788805139493, - "processedAt": 1788805197493, + "occurredAt": 1788907860142, + "processedAt": 1788907918142, "store": "fixture", "environment": "local-fixture", "projectId": "demo_project", @@ -104,7 +104,7 @@ "productId": "premium.monthly", "state": "Active", "active": true, - "expiresAt": 1788805257493, + "expiresAt": 1788907978142, "willRenew": true } }, @@ -115,11 +115,11 @@ }, { "event": { - "eventId": "40720621-a06e-4d0d-b3d9-36ded09162e1", + "eventId": "c0794713-9d34-4430-afd0-58b01c7f174f", "eventType": "subscription.canceled", "eventVersion": "1.0", - "occurredAt": 1788805140493, - "processedAt": 1788805197493, + "occurredAt": 1788907861142, + "processedAt": 1788907918142, "store": "fixture", "environment": "local-fixture", "projectId": "demo_project", @@ -129,7 +129,7 @@ "productId": "premium.monthly", "state": "Active", "active": true, - "expiresAt": 1788805257493, + "expiresAt": 1788907978142, "willRenew": false } }, @@ -140,11 +140,11 @@ }, { "event": { - "eventId": "8d10d963-0afd-4610-9cf0-fec12a53f230", + "eventId": "8a6b95a5-4c30-402f-851f-3be8105a0f44", "eventType": "subscription.expired", "eventVersion": "1.0", - "occurredAt": 1788805141493, - "processedAt": 1788805197493, + "occurredAt": 1788907862142, + "processedAt": 1788907918142, "store": "fixture", "environment": "local-fixture", "projectId": "demo_project", @@ -154,7 +154,7 @@ "productId": "premium.monthly", "state": "Expired", "active": false, - "expiresAt": 1788805196493, + "expiresAt": 1788907917142, "willRenew": false } }, @@ -165,11 +165,11 @@ }, { "event": { - "eventId": "b704818a-ca47-4d85-b5f5-da61ab75caa8", + "eventId": "24984b15-4f8b-48b2-b583-2d65399b2b79", "eventType": "entitlement.revoked", "eventVersion": "1.0", - "occurredAt": 1788805142493, - "processedAt": 1788805197493, + "occurredAt": 1788907863142, + "processedAt": 1788907918142, "store": "fixture", "environment": "local-fixture", "projectId": "demo_project", @@ -179,7 +179,7 @@ "productId": "premium.monthly", "state": "Expired", "active": false, - "expiresAt": 1788805196493, + "expiresAt": 1788907917142, "willRenew": false } }, @@ -190,11 +190,11 @@ }, { "event": { - "eventId": "c2bef40b-58ef-4fd1-a84e-e91e6c810a12", + "eventId": "0b66ad2e-6b25-470e-ab7c-61d305f58d71", "eventType": "subscription.refunded", "eventVersion": "1.0", - "occurredAt": 1788805143493, - "processedAt": 1788805197493, + "occurredAt": 1788907864142, + "processedAt": 1788907918142, "store": "fixture", "environment": "local-fixture", "projectId": "demo_project", @@ -204,7 +204,7 @@ "productId": "premium.monthly", "state": "Refunded", "active": false, - "expiresAt": 1788805196493, + "expiresAt": 1788907917142, "willRenew": false } }, @@ -216,10 +216,10 @@ ], "inboxCount": 7, "sourceHashes": { - "consumer.mjs": "3bd1a07e87e5e367775a3deadc71cd205dfc8e9757e632f2406791ae5db7ad73", - "webhooks.mjs": "60ee5a726ecd664e0e13b7eb3f44199abb5d600c696ccfca816e7d070104d2a0", + "consumer.mjs": "d8e168238270309157d52ca777fea30f1d37bae38b96e88148314c894bdb1b4c", + "webhooks.mjs": "4838bac7a9083440998409aa1ab543999495ea6a907036bc78ba2002feb588b2", "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", - "package.json": "6ff038e4eb85ed5e17e5e7b91149232a5c62e589e7a4083f152aa18dde944c92", + "package.json": "396fc93b7d50240739af2b817c172ffab8692245bd919de3b26ee713b56d455b", "package-lock.json": "6f5333cc45203d6a27fec82c9370a8c25fef7faab591467dd2ff3c79acd6ae1c" } } diff --git a/docs/build/guide.json b/docs/build/guide.json index 89a364e..0be06be 100644 --- a/docs/build/guide.json +++ b/docs/build/guide.json @@ -40,5 +40,12 @@ "review": "The reviewed final version adds the grant omitted by earlier checkpoints, so four events now reach the receiver. Tests cover binding/outbox rollback, expiry, ignored observations, delivery failures, and reopened SQLite storage. A second review added late-expiry and delayed-binding tests. This patch starts from the preceding reviewed revision. The final revision also checks the OpenIAP request boundary and a ready event receiver for businesses that supply only those roles. A third review verified proxy delivery and equal-time transitions. A publication review added standalone setup and recording guides, then repeated source and browser verification. The first Linux CI run exposed macOS metadata in historical archives; extraction now excludes it, new captures omit it, and a regression test verifies source hashes. The final review corrects the cancellation-at-expiry test timestamp so it reaches the expired-state guard, rather than only the stale-observation guard. A final byte-level review found that decoding before authentication accepted altered UTF-8 and an inserted BOM with the original signature. The receiver now authenticates raw bytes first; the same requests return 401 without storing an event.", "id": "06-recover-reviewed-8", "label": "Recover" + }, + { + "request": "Delete the account from provider records and delivered event copies, including concurrent delivery and restart.", + "change": "Added idempotent erasure, a persistent deletion ledger, receiver cleanup and the seventh dashboard step.", + "review": "The running example checks erasure during delivery, repeated jobs, storage reopening, late signed events and stale purchase requests. The shared app waits for in-flight fulfillment before provider erasure; one receiver accepts separately authenticated providers without event-ID collisions.", + "id": "07-account-erasure-interoperable-6", + "label": "Erase" } ] diff --git a/docs/build/verification.json b/docs/build/verification.json index 2eab99c..057079b 100644 --- a/docs/build/verification.json +++ b/docs/build/verification.json @@ -1,5 +1,5 @@ { - "recordedAt": "2026-09-07T18:20:43.843Z", + "recordedAt": "2026-09-08T22:51:18.098Z", "scope": "Each recorded archive extracted outside both repositories; patches applied in order from an empty directory; published dependencies installed with npm.", "results": [ { @@ -12,7 +12,7 @@ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 375ms\n" + "output": "\nadded 10 packages in 407ms\n" }, { "command": "npm test", @@ -31,7 +31,7 @@ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 362ms\n" + "output": "\nadded 10 packages in 240ms\n" }, { "command": "npm test", @@ -50,7 +50,7 @@ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 274ms\n" + "output": "\nadded 10 packages in 238ms\n" }, { "command": "npm test", @@ -69,7 +69,7 @@ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 254ms\n" + "output": "\nadded 10 packages in 269ms\n" }, { "command": "npm test", @@ -88,7 +88,7 @@ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 265ms\n" + "output": "\nadded 10 packages in 273ms\n" }, { "command": "npm test", @@ -107,7 +107,7 @@ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 269ms\n" + "output": "\nadded 10 packages in 255ms\n" }, { "command": "npm test", @@ -126,12 +126,12 @@ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 267ms\n" + "output": "\nadded 10 packages in 261ms\n" }, { "command": "npm test", "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && node --test checkpoint-tools.test.mjs\n\nCommerce Lab: 90 checks passed. No store or production service contacted.\n✔ patches preserve path-like source text across additions, changes, and deletions (79.4485ms)\nℹ tests 1\nℹ suites 0\nℹ pass 1\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 127.779166\n" + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && node --test checkpoint-tools.test.mjs\n\nCommerce Lab: 90 checks passed. No store or production service contacted.\n✔ patches preserve path-like source text across additions, changes, and deletions (59.002209ms)\nℹ tests 1\nℹ suites 0\nℹ pass 1\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 100.578291\n" } ] }, @@ -145,7 +145,7 @@ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 294ms\n" + "output": "\nadded 10 packages in 266ms\n" }, { "command": "npm test", @@ -155,7 +155,7 @@ { "command": "npm run test:tooling", "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) patches preserve path-like source text across additions, changes, and deletions [55.46ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.66ms]\n\n 2 pass\n 0 fail\nRan 2 tests across 1 file. [78.00ms]\n" + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) patches preserve path-like source text across additions, changes, and deletions [50.67ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.47ms]\n\n 2 pass\n 0 fail\nRan 2 tests across 1 file. [71.00ms]\n" } ] }, @@ -169,7 +169,7 @@ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 272ms\n" + "output": "\nadded 10 packages in 347ms\n" }, { "command": "npm test", @@ -179,7 +179,7 @@ { "command": "npm run test:tooling", "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) patches preserve path-like source text across additions, changes, and deletions [54.37ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.39ms]\n\n 2 pass\n 0 fail\nRan 2 tests across 1 file. [71.00ms]\n" + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) patches preserve path-like source text across additions, changes, and deletions [52.24ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.53ms]\n\n 2 pass\n 0 fail\nRan 2 tests across 1 file. [69.00ms]\n" } ] }, @@ -193,7 +193,7 @@ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 262ms\n" + "output": "\nadded 10 packages in 286ms\n" }, { "command": "npm test", @@ -203,7 +203,7 @@ { "command": "npm run test:tooling", "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) patches preserve path-like source text across additions, changes, and deletions [50.87ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.55ms]\n(pass) unfinished captures do not hide completed records [0.85ms]\n\n 3 pass\n 0 fail\nRan 3 tests across 1 file. [68.00ms]\n" + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) patches preserve path-like source text across additions, changes, and deletions [62.29ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.56ms]\n(pass) unfinished captures do not hide completed records [1.48ms]\n\n 3 pass\n 0 fail\nRan 3 tests across 1 file. [82.00ms]\n" } ] }, @@ -217,7 +217,7 @@ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 273ms\n" + "output": "\nadded 10 packages in 286ms\n" }, { "command": "npm test", @@ -227,7 +227,7 @@ { "command": "npm run test:tooling", "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) patches preserve path-like source text across additions, changes, and deletions [52.12ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.63ms]\n(pass) unfinished captures do not hide completed records [0.87ms]\n\n 3 pass\n 0 fail\nRan 3 tests across 1 file. [68.00ms]\n" + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) patches preserve path-like source text across additions, changes, and deletions [52.53ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.77ms]\n(pass) unfinished captures do not hide completed records [0.78ms]\n\n 3 pass\n 0 fail\nRan 3 tests across 1 file. [70.00ms]\n" } ] }, @@ -241,7 +241,7 @@ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 269ms\n" + "output": "\nadded 10 packages in 309ms\n" }, { "command": "npm test", @@ -251,7 +251,7 @@ { "command": "npm run test:tooling", "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [13.85ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [52.15ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.42ms]\n(pass) unfinished captures do not hide completed records [0.90ms]\n\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [88.00ms]\n" + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [12.45ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [50.19ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.54ms]\n(pass) unfinished captures do not hide completed records [0.68ms]\n\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [80.00ms]\n" } ] }, @@ -265,7 +265,7 @@ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 276ms\n" + "output": "\nadded 10 packages in 279ms\n" }, { "command": "npm test", @@ -275,7 +275,7 @@ { "command": "npm run test:tooling", "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [13.18ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [50.50ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.56ms]\n(pass) unfinished captures do not hide completed records [1.11ms]\n\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [83.00ms]\n" + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [11.25ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [50.24ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.52ms]\n(pass) unfinished captures do not hide completed records [0.79ms]\n\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [79.00ms]\n" } ] }, @@ -289,7 +289,7 @@ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 295ms\n" + "output": "\nadded 10 packages in 276ms\n" }, { "command": "npm test", @@ -299,7 +299,199 @@ { "command": "npm run test:tooling", "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [12.14ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [52.30ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.54ms]\n(pass) unfinished captures do not hide completed records [0.69ms]\n\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [82.00ms]\n" + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [11.15ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [52.33ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.47ms]\n(pass) unfinished captures do not hide completed records [1.45ms]\n\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [82.00ms]\n" + } + ] + }, + { + "id": "07-account-erasure", + "archiveSha256": "382af7547188b623958a204e07cc101996bc448d9fa6774d7128a377f708a074", + "patchSha256": "ff027cb4e540e0f1cf9886a8c78e884d2ea779ad465dbd739a70dee62a119664", + "sourceHashesMatch": true, + "patchAppliesExactly": true, + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 283ms\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && bun test composition/app-backend.test.mjs\n\nCommerce Protocol Example: 159 checks passed. No store or production service contacted.\nbun test v1.3.13 (bf2e2cec)\n\ncomposition/app-backend.test.mjs:\n(pass) account deletion waits for in-flight fulfillment and retries provider erasure after app restart [50.32ms]\n\n 1 pass\n 0 fail\n 6 expect() calls\nRan 1 test across 1 file. [116.00ms]\n" + }, + { + "command": "npm run test:tooling", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [12.33ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [52.82ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.55ms]\n(pass) unfinished captures do not hide completed records [0.88ms]\n\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [83.00ms]\n" + } + ] + }, + { + "id": "07-account-erasure-reviewed", + "archiveSha256": "8470f4c123f182446036700e268a7263dd94dd2eeb11f2fce18e2343c3ff584f", + "patchSha256": "640aff152ff7f504baa55fc79ec0597a0023a553b96a8b3dd06c9b529a93d3cf", + "sourceHashesMatch": true, + "patchAppliesExactly": true, + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 281ms\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && bun test composition/app-backend.test.mjs\n\nCommerce Protocol Example: 159 checks passed. No store or production service contacted.\nbun test v1.3.13 (bf2e2cec)\n\ncomposition/app-backend.test.mjs:\n(pass) account deletion waits for in-flight fulfillment and retries provider erasure after app restart [46.72ms]\n\n 1 pass\n 0 fail\n 6 expect() calls\nRan 1 test across 1 file. [113.00ms]\n" + }, + { + "command": "npm run test:tooling", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [12.12ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [52.56ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.57ms]\n(pass) unfinished captures do not hide completed records [1.02ms]\n\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [82.00ms]\n" + } + ] + }, + { + "id": "07-account-erasure-interoperable", + "archiveSha256": "c8f2d1ee5c2038196158d13698c3d573f175efd204f691714ab3495a63bedf17", + "patchSha256": "5c0b9e7cb060f8051c4c96aa224cdfe94f5437bab24ab7962f1d1730556c9c1a", + "sourceHashesMatch": true, + "patchAppliesExactly": true, + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 280ms\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && bun test composition/app-backend.test.mjs composition/receiver.test.mjs\n\nCommerce Protocol Example: 159 checks passed. No store or production service contacted.\nbun test v1.3.13 (bf2e2cec)\n\ncomposition/app-backend.test.mjs:\n(pass) account deletion waits for in-flight fulfillment and retries provider erasure after app restart [70.73ms]\n\ncomposition/receiver.test.mjs:\n(pass) one inbox scopes event IDs to authenticated emitters, survives upgrade, and discards erased users [5.17ms]\n\n 2 pass\n 0 fail\n 19 expect() calls\nRan 2 tests across 2 files. [141.00ms]\n" + }, + { + "command": "npm run test:tooling", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [12.26ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [50.87ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.52ms]\n(pass) unfinished captures do not hide completed records [0.83ms]\n\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [82.00ms]\n" + } + ] + }, + { + "id": "07-account-erasure-interoperable-2", + "archiveSha256": "958e7cec476ca8254254f2222d361090c3e0d6599e74064381674ca9ba08de51", + "patchSha256": "61c76c1785e50e3e48cfa541d8ac9173df09746430f534b792ce16e3d2a84c96", + "sourceHashesMatch": true, + "patchAppliesExactly": true, + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 282ms\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && bun test composition/app-backend.test.mjs composition/receiver.test.mjs\n\nCommerce Protocol Example: 159 checks passed. No store or production service contacted.\nbun test v1.3.13 (bf2e2cec)\n\ncomposition/app-backend.test.mjs:\n(pass) account deletion waits for in-flight fulfillment and retries provider erasure after app restart [81.75ms]\n\ncomposition/receiver.test.mjs:\n(pass) the original SQLite and memory composition remains compatible with the receiver [54.54ms]\n(pass) one inbox scopes event IDs to authenticated emitters, survives upgrade, and discards erased users [5.18ms]\n\n 3 pass\n 0 fail\n 20 expect() calls\nRan 3 tests across 2 files. [251.00ms]\n" + }, + { + "command": "npm run test:tooling", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [12.64ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [57.15ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.47ms]\n(pass) unfinished captures do not hide completed records [1.04ms]\n(pass) checkpoint replay follows predecessors instead of directory name order [1.42ms]\n\n 5 pass\n 0 fail\nRan 5 tests across 1 file. [90.00ms]\n" + } + ] + }, + { + "id": "07-account-erasure-interoperable-3", + "archiveSha256": "9f688f195dac38d32f7fd389e64f6cda3bb13691798869cd55e1c0d1f78c9388", + "patchSha256": "b7e551872a03c2e65744988ad448fabd4e9c2c7632ba89873d41604c8bd3765b", + "sourceHashesMatch": true, + "patchAppliesExactly": true, + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 318ms\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && bun test composition/app-backend.test.mjs composition/receiver.test.mjs\n\nCommerce Protocol Example: 159 checks passed. No store or production service contacted.\nbun test v1.3.13 (bf2e2cec)\n\ncomposition/app-backend.test.mjs:\n(pass) account deletion waits for in-flight fulfillment and retries provider erasure after app restart [68.02ms]\n\ncomposition/receiver.test.mjs:\n(pass) the original SQLite and memory composition remains compatible with the receiver [43.15ms]\n(pass) one inbox scopes event IDs to authenticated emitters, survives upgrade, and discards erased users [4.11ms]\n\n 3 pass\n 0 fail\n 20 expect() calls\nRan 3 tests across 2 files. [187.00ms]\n" + }, + { + "command": "npm run test:tooling", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [11.89ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [51.06ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.42ms]\n(pass) unfinished captures do not hide completed records [0.92ms]\n(pass) checkpoint replay follows predecessors instead of directory name order [1.10ms]\n\n 5 pass\n 0 fail\nRan 5 tests across 1 file. [82.00ms]\n" + } + ] + }, + { + "id": "07-account-erasure-interoperable-4", + "archiveSha256": "a4b9e26bff92a0b097c754cc0f6ba4c51be676d2efe85d597daaf9fb60734402", + "patchSha256": "bd4061242e9a5ae9241af255d4e2d12bf53463b008ff90c6c98008e8af3c912e", + "sourceHashesMatch": true, + "patchAppliesExactly": true, + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 413ms\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && bun test composition/app-backend.test.mjs composition/receiver.test.mjs\n\nCommerce Protocol Example: 213 checks passed. No store or production service contacted.\nbun test v1.3.13 (bf2e2cec)\n\ncomposition/app-backend.test.mjs:\n(pass) account deletion waits for in-flight fulfillment and retries provider erasure after app restart [75.34ms]\n\ncomposition/receiver.test.mjs:\n(pass) the original SQLite and memory composition remains compatible with the receiver [58.99ms]\n(pass) one inbox scopes event IDs to authenticated emitters, survives upgrade, and discards erased users [7.18ms]\n\n 3 pass\n 0 fail\n 20 expect() calls\nRan 3 tests across 2 files. [216.00ms]\n" + }, + { + "command": "npm run test:tooling", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [12.41ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [64.06ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.66ms]\n(pass) unfinished captures do not hide completed records [1.17ms]\n(pass) checkpoint replay follows predecessors instead of directory name order [2.04ms]\n\n 5 pass\n 0 fail\nRan 5 tests across 1 file. [97.00ms]\n" + } + ] + }, + { + "id": "07-account-erasure-interoperable-5", + "archiveSha256": "7f2c16e87db038c949d99873b75ae12ea6a081775b46f67567b4d9cc0ca6c57e", + "patchSha256": "97d80477c846449a93b48487468229111103fb6f0c02f291accaf4bbe401e6c4", + "sourceHashesMatch": true, + "patchAppliesExactly": true, + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 471ms\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && bun test composition/app-backend.test.mjs composition/receiver.test.mjs\n\nCommerce Protocol Example: 213 checks passed. No store or production service contacted.\nbun test v1.3.13 (bf2e2cec)\n\ncomposition/app-backend.test.mjs:\n(pass) account deletion waits for in-flight fulfillment and retries provider erasure after app restart [101.58ms]\n\ncomposition/receiver.test.mjs:\n(pass) the original SQLite and memory composition remains compatible with the receiver [70.35ms]\n(pass) one inbox scopes event IDs to authenticated emitters, survives upgrade, and discards erased users [6.20ms]\n\n 3 pass\n 0 fail\n 20 expect() calls\nRan 3 tests across 2 files. [288.00ms]\n" + }, + { + "command": "npm run test:tooling", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [15.24ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [56.47ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.55ms]\n(pass) unfinished captures do not hide completed records [1.12ms]\n(pass) checkpoint replay follows predecessors instead of directory name order [1.84ms]\n\n 5 pass\n 0 fail\nRan 5 tests across 1 file. [99.00ms]\n" + } + ] + }, + { + "id": "07-account-erasure-interoperable-6", + "archiveSha256": "e0a75086014cbc5ecd7f42d39342cdc1be1d5e03aadc401bd4b2092ad3e875b3", + "patchSha256": "e53409eeedf577436a0b5d7c5b673f5742ff253d0a5f5ad09301fe90bae5827c", + "sourceHashesMatch": true, + "patchAppliesExactly": true, + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 304ms\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs && bun test composition/app-backend.test.mjs composition/receiver.test.mjs\n\nCommerce Protocol Example: 229 checks passed. No store or production service contacted.\nbun test v1.3.13 (bf2e2cec)\n\ncomposition/app-backend.test.mjs:\n(pass) account deletion waits for in-flight fulfillment and retries provider erasure after app restart [72.34ms]\n\ncomposition/receiver.test.mjs:\n(pass) the original SQLite and memory composition remains compatible with the receiver [63.30ms]\n(pass) one inbox scopes event IDs to authenticated emitters, survives upgrade, and discards erased users [6.49ms]\n\n 3 pass\n 0 fail\n 20 expect() calls\nRan 3 tests across 2 files. [212.00ms]\n" + }, + { + "command": "npm run test:tooling", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test:tooling\n> bun test checkpoint-tools.test.mjs\n\nbun test v1.3.13 (bf2e2cec)\n\ncheckpoint-tools.test.mjs:\n(pass) archive extraction excludes macOS metadata and preserves source hashes [12.75ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [67.75ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.44ms]\n(pass) unfinished captures do not hide completed records [1.13ms]\n(pass) checkpoint replay follows predecessors instead of directory name order [1.44ms]\n\n 5 pass\n 0 fail\nRan 5 tests across 1 file. [102.00ms]\n" } ] } diff --git a/docs/recording.md b/docs/recording.md index bd0dacc..e26e349 100644 --- a/docs/recording.md +++ b/docs/recording.md @@ -10,14 +10,14 @@ checkpoint to make earlier work look correct. 2. Run `npm test` and inspect the dashboard. Fix any failing behavior and repeat its check. Keep failure output; do not invent a failure for the narrative. 3. Set a new `checkpoint.json.id` and point `previous` at the last completed - record. IDs are sorted lexically when verifying patches, so keep the new ID - after its predecessor. `step` is the demonstrated milestone, not the number - of review revisions. + record. Verification follows each record's `previous` link, so its predecessor + runs first regardless of folder name. `step` is the demonstrated milestone, + not the number of review revisions. 4. Run `npm run capture`. This requires Bun, Node.js/npm, Git, tar, and Google Chrome. Capture installs/tests an isolated source archive, runs every available dashboard step, and checks desktop/mobile screens. 5. Open the saved PNGs. Update `docs/build/guide.json` if the new record should - become one of the six featured milestones; keep all earlier records. + become one of the seven featured milestones; keep all earlier records. 6. Run `npm run verify:checkpoints` to apply the full patch chain from an empty directory and install/test every archive independently. @@ -51,7 +51,7 @@ bun export-docs.mjs /path/to/documentation-assets The receiver report must identify the selected final source. Export rejects source drift, missing archive verification, or a mismatched consumer report. It copies the -six featured milestones and extracts both AI briefs from the selected final +seven featured milestones and extracts both AI briefs from the selected final archive, so the published instructions match the code they describe. [Back to the example](../README.md) · [Build history](build/README.md) diff --git a/erasure.mjs b/erasure.mjs new file mode 100644 index 0000000..fb977b1 --- /dev/null +++ b/erasure.mjs @@ -0,0 +1,36 @@ +import { createHmac, randomBytes, randomUUID } from "node:crypto"; + +// Retain a stable retry marker without storing the user ID verbatim. +export function createErasureLedger(db) { + db.exec(` + PRAGMA secure_delete = ON; + CREATE TABLE IF NOT EXISTS erasure_key (id INTEGER PRIMARY KEY CHECK (id = 1), value TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS erased_users (user_hash TEXT PRIMARY KEY, job_id TEXT NOT NULL); + `); + db.query("INSERT OR IGNORE INTO erasure_key VALUES (1, ?)").run( + randomBytes(32).toString("hex"), + ); + const key = db + .query("SELECT value FROM erasure_key WHERE id = 1") + .get().value; + const hash = (userId) => + createHmac("sha256", key).update(userId).digest("hex"); + return { + has: (userId) => + Boolean( + db + .query("SELECT 1 FROM erased_users WHERE user_hash = ?") + .get(hash(userId)), + ), + remember(userId) { + const userHash = hash(userId); + db.query("INSERT OR IGNORE INTO erased_users VALUES (?, ?)").run( + userHash, + randomUUID(), + ); + return db + .query("SELECT job_id FROM erased_users WHERE user_hash = ?") + .get(userHash).job_id; + }, + }; +} diff --git a/export-docs.mjs b/export-docs.mjs index f7da37e..4dbddd0 100644 --- a/export-docs.mjs +++ b/export-docs.mjs @@ -29,8 +29,15 @@ const selected = guide.map((step) => { return record; }); const last = selected.at(-1); -assert.deepEqual(selected.map((record) => record.step), [1, 2, 3, 4, 5, 6]); -assert(guide.every((step) => typeof step.label === "string" && step.label.length > 0)); +assert.deepEqual( + selected.map((record) => record.step), + guide.map((_, index) => index + 1), +); +assert( + guide.every( + (step) => typeof step.label === "string" && step.label.length > 0, + ), +); assert.deepEqual( Object.fromEntries( SOURCE_FILES.sort().map((name) => [ diff --git a/package.json b/package.json index 36f1f41..8186b2e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "start": "bun server.mjs", - "test": "bun verify.mjs", + "test": "bun verify.mjs && bun test composition/app-backend.test.mjs composition/receiver.test.mjs", "capture": "bun capture.mjs", "verify:checkpoints": "bun verify-checkpoints.mjs", "test:tooling": "bun test checkpoint-tools.test.mjs", diff --git a/provider.mjs b/provider.mjs index 643c03c..3e23093 100644 --- a/provider.mjs +++ b/provider.mjs @@ -6,6 +6,7 @@ import { HTTP_BINDING, } from "openiap-commerce-protocol"; import { protocolError, validate } from "./contract.mjs"; +import { createErasureLedger } from "./erasure.mjs"; export const FIXTURE = Object.freeze({ store: "fixture", @@ -29,16 +30,44 @@ export function isEntitled(state, expiresAt, now) { } // This adapter recognizes one fictional purchase; it never contacts a store. -function verifyFixture(input) { - if (input.store !== FIXTURE.store) return { error: "UNSUPPORTED_STORE" }; - if (typeof input.evidence !== "string") return { error: "INVALID_REQUEST" }; - if (input.evidence === "local-upstream-outage") { +function fixtureEvidence(input) { + switch (input.store) { + case "apple": + return input.apple?.jws; + case "google": + return input.google?.purchaseToken; + case "amazon": + return ( + input.amazon && + JSON.stringify([ + input.amazon.userId, + input.amazon.receiptId, + input.amazon.sandbox === true, + ]) + ); + case "horizon": + return ( + input.horizon && + JSON.stringify([input.horizon.userId, input.horizon.sku]) + ); + default: + return input.evidence; + } +} + +function verifyFixture(input, fixture) { + if (input.store !== fixture.store) return { error: "UNSUPPORTED_STORE" }; + const evidence = fixtureEvidence(input); + if (typeof evidence !== "string") return { error: "INVALID_REQUEST" }; + if (evidence === "local-upstream-outage") { return { error: "VERIFICATION_FAILED" }; } - return { accepted: input.evidence === FIXTURE.evidence }; + const current = fixture.currentVerdict?.(); + if (current === "outage") return { error: "VERIFICATION_FAILED" }; + return { accepted: evidence === fixture.evidence && current !== false }; } -export function createProvider(path, now) { +export function createProvider(path, now, fixture = FIXTURE) { const db = new Database(path, { create: true }); db.exec(` PRAGMA journal_mode = WAL; @@ -54,13 +83,23 @@ export function createProvider(path, now) { next_at INTEGER NOT NULL DEFAULT 0 ); `); + const erasures = createErasureLedger(db); + if ( + !db + .query("PRAGMA table_info(purchases)") + .all() + .some((column) => column.name === "erased") + ) + db.exec( + "ALTER TABLE purchases ADD COLUMN erased INTEGER NOT NULL DEFAULT 0", + ); function snapshot(row) { return { productId: row.product_id, state: row.state, active: isEntitled(row.state, row.expires_at, now()), - store: FIXTURE.store, + store: fixture.store, expiresAt: row.expires_at, willRenew: Boolean(row.will_renew), }; @@ -71,6 +110,18 @@ export function createProvider(path, now) { } function entitlements(userId) { + if (fixture.pointInTime) { + const current = fixture.currentVerdict?.(); + if (current === "outage") return { error: "VERIFICATION_FAILED" }; + return { + userId, + productIds: + current === false + ? [] + : [...new Set(rowsFor(userId).map((row) => row.product_id))], + subscriptions: [], + }; + } const subscriptions = rowsFor(userId) .map(snapshot) .filter((row) => row.active); @@ -104,12 +155,18 @@ export function createProvider(path, now) { }, eventTypes, stores: { - fixture: Object.fromEntries( + [fixture.store]: Object.fromEntries( capabilityNames.map((key) => [ key, { - provider: supported.has(key), - implementation: supported.has(key), + provider: + supported.has(key) && + (!fixture.pointInTime || + ["initialValidation", "entitlements"].includes(key)), + implementation: + supported.has(key) && + (!fixture.pointInTime || + ["initialValidation", "entitlements"].includes(key)), notes: "Local fixture demonstration only; no real store integration or profile conformance claim.", }, @@ -125,7 +182,7 @@ export function createProvider(path, now) { eventVersion: COMMERCE_EVENT_VERSION, occurredAt, processedAt: now(), - store: FIXTURE.store, + store: fixture.store, environment: "local-fixture", projectId: "commerce_example", productId: row.product_id, @@ -143,45 +200,51 @@ export function createProvider(path, now) { const handlers = { providerCapabilities: () => capabilities, verifyPurchase(input) { - const verdict = verifyFixture(input); + const verdict = verifyFixture(input, fixture); if (verdict.error) return verdict; if (verdict.accepted) { db.query( `INSERT OR IGNORE INTO purchases (fingerprint, user_id, product_id, state, expires_at, will_renew, observed_at) VALUES (?, NULL, ?, 'Active', ?, 1, ?)`, ).run( - fingerprint(input.evidence), - FIXTURE.productId, - FIXTURE.expiresAt, - FIXTURE.startsAt, + fingerprint(fixtureEvidence(input)), + fixture.productId, + fixture.expiresAt, + fixture.startsAt, ); } + const expired = !fixture.pointInTime && now() >= fixture.expiresAt; return { - store: FIXTURE.store, - isValid: verdict.accepted && now() < FIXTURE.expiresAt, + store: fixture.store, + isValid: verdict.accepted && !expired, state: !verdict.accepted ? "INAUTHENTIC" - : now() >= FIXTURE.expiresAt + : expired ? "EXPIRED" : "ENTITLED", - ...(verdict.accepted ? { productId: FIXTURE.productId } : {}), + ...(verdict.accepted ? { productId: fixture.productId } : {}), environment: "local-fixture", }; }, bindPurchase(input) { - if (input.store !== FIXTURE.store) return { error: "UNSUPPORTED_STORE" }; - if (typeof input.evidence !== "string") + if (input.store !== fixture.store) return { error: "UNSUPPORTED_STORE" }; + if (typeof fixtureEvidence(input) !== "string") return { error: "INVALID_REQUEST" }; return db.transaction(() => { - const key = fingerprint(input.evidence); + if (erasures.has(input.userId)) return { bound: false }; + const key = fingerprint(fixtureEvidence(input)); const updated = db .query( - "UPDATE purchases SET user_id = ? WHERE fingerprint = ? AND user_id IS NULL", + "UPDATE purchases SET user_id = ? WHERE fingerprint = ? AND user_id IS NULL AND erased = 0", ) .run(input.userId, key); const row = db .query("SELECT * FROM purchases WHERE fingerprint = ?") .get(key); - if (updated.changes && isEntitled(row.state, row.expires_at, now())) { + if ( + updated.changes && + !fixture.pointInTime && + isEntitled(row.state, row.expires_at, now()) + ) { enqueue("entitlement.granted", row, row.observed_at); db.query( "UPDATE purchases SET entitlement_granted = 1 WHERE fingerprint = ?", @@ -191,7 +254,21 @@ export function createProvider(path, now) { })(); }, entitlements: (input) => entitlements(input.userId), + eraseUser(input) { + return db.transaction(() => { + const jobId = erasures.remember(input.userId); + db.query( + "UPDATE purchases SET user_id = NULL, erased = 1, entitlement_granted = 0 WHERE user_id = ?", + ).run(input.userId); + // A claimed delivery may already be in flight; the receiver erases its own copy. + db.query( + "DELETE FROM outbox WHERE json_extract(body, '$.userId') = ?", + ).run(input.userId); + return { accepted: true, jobId, status: "completed" }; + })(); + }, subscriptionStatus(input) { + if (fixture.pointInTime) return { active: false }; const snapshots = rowsFor(input.userId).map(snapshot); const subscription = snapshots.find((row) => row.active) ?? snapshots[0]; return { @@ -251,7 +328,7 @@ export function createProvider(path, now) { return false; const row = db .query("SELECT * FROM purchases WHERE fingerprint = ?") - .get(fingerprint(FIXTURE.evidence)); + .get(fingerprint(fixture.evidence)); if (!row) throw new Error("Verify the fixture purchase first"); if (kind === "expire" && occurredAt < row.expires_at) { throw new Error("Premature expiry requires store reconciliation"); @@ -295,7 +372,7 @@ export function createProvider(path, now) { "SELECT user_id AS userId, product_id AS productId, state, will_renew AS willRenew FROM purchases", ) .all(), - access: entitlements(FIXTURE.userId), + access: entitlements(fixture.userId), deliveries: db .query( "SELECT event_id AS eventId, delivery_id AS deliveryId, attempts, status, body FROM outbox ORDER BY rowid", diff --git a/scenario.mjs b/scenario.mjs index 525327c..492dc3c 100644 --- a/scenario.mjs +++ b/scenario.mjs @@ -36,6 +36,12 @@ export const STAGES = [ result: "Access closes at the deadline. Restarting preserves purchases and deliveries.", }, + { + title: "Delete the account", + built: "Idempotent erasure + receiver cleanup + durable deletion guard", + result: + "Alice is removed from purchases and event copies. Late deliveries cannot restore her account data.", + }, ]; export async function requestOperation(baseUrl, name, input, role = "server") { @@ -293,6 +299,44 @@ export function createScenario(runtime) { .active, false, ); + } else if (stage === 6) { + check( + "Verification credentials cannot erase users", + (await run("eraseUser", { userId: FIXTURE.userId }, "verification")) + .httpStatus, + 403, + ); + // The app removes its event copies before requesting provider erasure. + runtime.receiver.eraseUser(FIXTURE.userId); + const erased = await run("eraseUser", { userId: FIXTURE.userId }); + check( + "Provider erasure completes", + [erased.body.accepted, erased.body.status], + [true, "completed"], + ); + runtime.restart(); + check( + "Erasure retry after restart returns the same job", + (await run("eraseUser", { userId: FIXTURE.userId })).body, + erased.body, + ); + check( + "Purchase has no account identity", + runtime.provider.inspect().purchases.map((row) => row.userId), + [null], + ); + check("App event copies are erased", runtime.receiver.count(), 0); + check( + "Erased account has no access", + (await run("entitlements", { userId: FIXTURE.userId })).body.productIds, + [], + ); + check( + "A stale binding retry cannot restore identity", + (await run("bindPurchase", { ...evidence, userId: FIXTURE.userId })) + .body.bound, + false, + ); } const entry = { step: stage + 1, diff --git a/verify-erasure.mjs b/verify-erasure.mjs new file mode 100644 index 0000000..e3e9d7e --- /dev/null +++ b/verify-erasure.mjs @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import { rmSync } from "node:fs"; +import { startLab } from "./server.mjs"; +import { FIXTURE } from "./provider.mjs"; +import { requestOperation, STAGES } from "./scenario.mjs"; +import { deliver, sign } from "./webhooks.mjs"; +import { WEBHOOK } from "openiap-commerce-protocol"; + +export async function verifyErasure() { + const checks = []; + const check = (name, actual, expected) => { + assert.deepEqual(actual, expected, name); + checks.push(name); + }; + const lab = startLab(); + try { + const { runtime } = lab; + const call = (name, input, role) => + requestOperation(runtime.baseUrl, name, input, role); + const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence }; + await call("verifyPurchase", evidence); + await call("bindPurchase", { ...evidence, userId: FIXTURE.userId }); + const pending = runtime.provider.db + .query("SELECT body FROM outbox LIMIT 1") + .get().body; + const post = (body) => { + const timestamp = String(Math.floor(runtime.now() / 1000)); + return runtime.post({ + method: "POST", + body, + headers: { + [WEBHOOK.timestampHeader]: timestamp, + [WEBHOOK.signatureHeader]: sign(runtime.secret, timestamp, body), + [WEBHOOK.eventIdHeader]: JSON.parse(body).eventId, + }, + }); + }; + await post(pending); + check( + "An active purchase has a delivered event copy", + runtime.receiver.count(), + 1, + ); + runtime.receiver.eraseUser(FIXTURE.userId); + let erased; + await deliver( + runtime.provider, + runtime.secret, + runtime.now, + async (init) => { + erased = await call("eraseUser", { userId: FIXTURE.userId }); + return runtime.post(init); + }, + ); + check("Erasure during delivery completes", erased.body.status, "completed"); + check( + "An in-flight event cannot resurrect receiver data", + runtime.receiver.count(), + 0, + ); + check( + "An in-flight acknowledgement cannot resurrect the outbox", + runtime.provider.inspect().deliveries, + [], + ); + runtime.restart(); + check( + "Repeated erase survives restart", + (await call("eraseUser", { userId: FIXTURE.userId })).body, + erased.body, + ); + check( + "An old signed event remains discarded after restart", + (await (await post(pending)).json()).discarded, + "erased-user", + ); + const late = JSON.stringify({ + ...JSON.parse(pending), + eventId: "late-new-event", + }); + check( + "A new event ID cannot bypass erasure", + (await (await post(late)).json()).discarded, + "erased-user", + ); + check( + "Verification cannot bind an erased purchase", + (await call("verifyPurchase", evidence)).body.isValid, + true, + ); + check( + "Stale binding cannot restore an erased account", + (await call("bindPurchase", { ...evidence, userId: FIXTURE.userId })).body + .bound, + false, + ); + check( + "Another account cannot claim erased evidence", + (await call("bindPurchase", { ...evidence, userId: "demo_bob" })).body + .bound, + false, + ); + check( + "Erased account is inactive before paid expiry", + (await call("subscriptionStatus", { userId: FIXTURE.userId })).body, + { active: false }, + ); + runtime.time = FIXTURE.expiresAt; + runtime.provider.observe({ + id: "expiry-after-deletion", + kind: "expire", + occurredAt: runtime.time, + }); + await deliver(runtime.provider, runtime.secret, runtime.now, runtime.post); + check( + "Late lifecycle processing carries no erased identity", + runtime.receiver.inspect().map((event) => event.userId), + [undefined], + ); + const serialized = JSON.stringify([ + runtime.provider.db.query("SELECT * FROM purchases").all(), + runtime.provider.db.query("SELECT * FROM outbox").all(), + runtime.provider.db.query("SELECT * FROM erased_users").all(), + runtime.receiver.inspect(), + ]); + check( + "Persisted protocol records contain no erased user ID", + serialized.includes(FIXTURE.userId), + false, + ); + check( + "Unknown-user erasure is accepted", + (await call("eraseUser", { userId: "missing_user" })).body.accepted, + true, + ); + } finally { + await lab.close(); + rmSync(lab.directory, { recursive: true, force: true }); + } + const walkthrough = startLab(); + try { + for (let i = 0; i < STAGES.length; i++) + await walkthrough.scenario.advance(); + checks.push(...walkthrough.scenario.history.at(-1).checks); + } finally { + await walkthrough.close(); + rmSync(walkthrough.directory, { recursive: true, force: true }); + } + return checks; +} + +if (import.meta.main) + console.log(`${(await verifyErasure()).length} erasure checks passed.`); diff --git a/verify-stores.mjs b/verify-stores.mjs new file mode 100644 index 0000000..01bc808 --- /dev/null +++ b/verify-stores.mjs @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import { createProvider, CREDENTIALS } from "./provider.mjs"; +import { operation } from "./contract.mjs"; +import { toVerifyPurchaseInput } from "./client-bridge.mjs"; + +export async function verifyStores() { + const checks = []; + for (const store of ["apple", "google", "amazon", "horizon"]) { + let current = true; + let time = Date.now(); + const input = toVerifyPurchaseInput( + { store, purchaseToken: "fictional-proof", productId: "premium.monthly" }, + { storeUserId: "fixture-store-user", amazonSandbox: true }, + ); + const evidence = + store === "amazon" + ? JSON.stringify(["fixture-store-user", "fictional-proof", true]) + : store === "horizon" + ? JSON.stringify(["fixture-store-user", "premium.monthly"]) + : "fictional-proof"; + const fixture = { + store, + evidence, + userId: "alice", + productId: "premium.monthly", + startsAt: time, + expiresAt: time + 60000, + pointInTime: ["amazon", "horizon"].includes(store), + currentVerdict: () => current, + }; + const provider = createProvider(":memory:", () => time, fixture); + const check = (label, actual, expected) => { + assert.deepEqual(actual, expected, `${store}: ${label}`); + checks.push(`${store}: ${label}`); + }; + async function call(name, body, credential = CREDENTIALS.server) { + const spec = operation(name), + url = new URL(spec.path, "http://fixture.invalid"); + if (spec.method === "GET") + for (const [key, value] of Object.entries(body ?? {})) + url.searchParams.set(key, value); + const response = await provider.fetch( + new Request(url, { + method: spec.method, + headers: { + authorization: credential, + "content-type": "application/json", + }, + ...(spec.method === "POST" ? { body: JSON.stringify(body) } : {}), + }), + ); + return { status: response.status, result: await response.json() }; + } + try { + check( + "unverified evidence cannot bind", + (await call("bindPurchase", { ...input, userId: "alice" })).result + .bound, + false, + ); + check( + "matching evidence verifies", + (await call("verifyPurchase", input)).result.isValid, + true, + ); + check( + "verification alone gives no access", + (await call("entitlements", { userId: "alice" })).result.productIds, + [], + ); + check( + "verification credentials cannot bind", + ( + await call( + "bindPurchase", + { ...input, userId: "alice" }, + CREDENTIALS.verification, + ) + ).status, + 403, + ); + for (let i = 0; i < 2; i++) + check( + "binding and retry keep one owner", + (await call("bindPurchase", { ...input, userId: "alice" })).result + .bound, + true, + ); + check( + "another account cannot claim the purchase", + (await call("bindPurchase", { ...input, userId: "bob" })).result.bound, + false, + ); + check( + "owned product is accessible", + (await call("entitlements", { userId: "alice" })).result.productIds, + ["premium.monthly"], + ); + if (fixture.pointInTime) { + current = "outage"; + check( + "an outage is an error, not cached access", + (await call("entitlements", { userId: "alice" })).status, + 502, + ); + current = false; + check( + "a negative recheck removes access", + (await call("entitlements", { userId: "alice" })).result.productIds, + [], + ); + current = true; + check( + "a confirmed recheck restores ownership", + (await call("entitlements", { userId: "alice" })).result.productIds, + ["premium.monthly"], + ); + } + check( + "erasure completes", + (await call("eraseUser", { userId: "alice" })).result.status, + "completed", + ); + check( + "erasure removes access", + (await call("entitlements", { userId: "alice" })).result.productIds, + [], + ); + check( + "erased evidence cannot be claimed", + (await call("bindPurchase", { ...input, userId: "bob" })).result.bound, + false, + ); + for (const [label, offset] of [ + ["before", -1], + ["at", 0], + ["after", 1], + ]) { + time = fixture.expiresAt + offset; + const verdict = (await call("verifyPurchase", input)).result; + check( + `verification ${label} the fixture deadline respects the store's access model`, + [verdict.isValid, verdict.state], + fixture.pointInTime || offset < 0 + ? [true, "ENTITLED"] + : [false, "EXPIRED"], + ); + } + if (fixture.pointInTime) { + current = false; + const rejected = (await call("verifyPurchase", input)).result; + check( + "a negative ownership verdict remains rejected after the fixture deadline", + [rejected.isValid, rejected.state], + [false, "INAUTHENTIC"], + ); + current = "outage"; + check( + "an ownership verification outage remains an error after the fixture deadline", + (await call("verifyPurchase", input)).status, + 502, + ); + } + } finally { + provider.close(); + } + } + return checks; +} +if (import.meta.main) + console.log( + `Store fixtures: ${(await verifyStores()).length} checks passed. No store contacted.`, + ); diff --git a/verify.mjs b/verify.mjs index 0c0f246..424d7dd 100644 --- a/verify.mjs +++ b/verify.mjs @@ -8,6 +8,8 @@ import { runConsumerDemo } from "./consumer.mjs"; import { runBridgeDemo } from "./client-bridge.mjs"; import { startLab } from "./server.mjs"; import { authentic, createReceiver, deliver, sign } from "./webhooks.mjs"; +import { verifyStores } from "./verify-stores.mjs"; +import { verifyErasure } from "./verify-erasure.mjs"; export async function verifyLab({ compareSigner } = {}) { const lab = startLab(); @@ -96,9 +98,10 @@ export async function verifyLab({ compareSigner } = {}) { "UNSUPPORTED_STORE", ); check( - "Erasure is explicitly unimplemented", - (await call("eraseUser", { userId: FIXTURE.userId })).body.error.code, - "UNSUPPORTED_PROFILE", + "Erasure rejects verification credentials", + (await call("eraseUser", { userId: FIXTURE.userId }, "verification")) + .httpStatus, + 403, ); check( "Cancellation after expiry is ignored", @@ -425,6 +428,8 @@ export async function verifyLab({ compareSigner } = {}) { } checks.push(...(await runConsumerDemo()).checks); checks.push(...runBridgeDemo()); + checks.push(...(await verifyStores())); + checks.push(...(await verifyErasure())); return checks; } diff --git a/webhooks.mjs b/webhooks.mjs index d7035a2..f9b059d 100644 --- a/webhooks.mjs +++ b/webhooks.mjs @@ -2,6 +2,7 @@ import { Database } from "bun:sqlite"; import { createHmac, timingSafeEqual } from "node:crypto"; import { WEBHOOK } from "openiap-commerce-protocol"; import { validate } from "./contract.mjs"; +import { createErasureLedger } from "./erasure.mjs"; export function sign(secret, timestamp, body) { return ( @@ -33,22 +34,58 @@ export function authentic(secrets, timestamp, body, signatures, nowSeconds) { } export function createReceiver(path, secret, now) { + const emitters = + typeof secret === "string" ? [{ name: "default", secret }] : secret; + if ( + !Array.isArray(emitters) || + !emitters.length || + emitters.some( + (entry) => + !entry.name || + !entry.secret || + (typeof secret !== "string" && !entry.projectId), + ) + ) + throw new Error( + "Configure each emitter with a name, project ID, and signing secret", + ); + if (new Set(emitters.map((entry) => entry.name)).size !== emitters.length) + throw new Error("Emitter names must be unique"); const db = new Database(path, { create: true }); db.exec( "CREATE TABLE IF NOT EXISTS inbox (event_id TEXT PRIMARY KEY, body TEXT NOT NULL)", ); + const erasures = createErasureLedger(db); + // Upgrade old single-emitter event IDs without losing durable deduplication. + db.transaction(() => { + for (const row of db.query("SELECT event_id, body FROM inbox").all()) { + const event = JSON.parse(row.body); + if (row.event_id !== event.eventId) continue; + const matching = emitters.filter( + (entry) => entry.projectId === event.projectId, + ); + const name = matching.length === 1 ? matching[0].name : "default"; + const identity = JSON.stringify([name, event.projectId, event.eventId]); + db.query("INSERT OR IGNORE INTO inbox VALUES (?, ?)").run( + identity, + row.body, + ); + db.query("DELETE FROM inbox WHERE event_id = ?").run(row.event_id); + } + })(); async function fetch(request) { const bytes = new Uint8Array(await request.arrayBuffer()); - if ( - !authentic( - [secret], + const authenticated = emitters.filter((emitter) => + authentic( + [emitter.secret], request.headers.get(WEBHOOK.timestampHeader), bytes, request.headers.get(WEBHOOK.signatureHeader), Math.floor(now() / 1000), - ) - ) { + ), + ); + if (!authenticated.length) { return new Response("Invalid signature", { status: 401 }); } let body, event; @@ -62,15 +99,38 @@ export function createReceiver(path, secret, now) { return new Response("Invalid event", { status: 400 }); if (request.headers.get(WEBHOOK.eventIdHeader) !== event.eventId) return new Response("Event ID mismatch", { status: 400 }); + const emitter = authenticated.find( + (entry) => !entry.projectId || entry.projectId === event.projectId, + ); + if (!emitter) + return new Response("Unexpected emitter project", { status: 401 }); + if (event.userId && erasures.has(event.userId)) + return Response.json({ accepted: true, discarded: "erased-user" }); // Inbox insertion is the durable effect; downstream jobs can consume it later. const result = db .query("INSERT OR IGNORE INTO inbox VALUES (?, ?)") - .run(event.eventId, body); + .run( + JSON.stringify([emitter.name, event.projectId, event.eventId]), + body, + ); return Response.json({ accepted: true, duplicate: result.changes === 0 }); } return { fetch, + eraseUser(userId) { + return db.transaction(() => { + erasures.remember(userId); + return db + .query("DELETE FROM inbox WHERE json_extract(body, '$.userId') = ?") + .run(userId).changes; + })(); + }, + inspect: () => + db + .query("SELECT body FROM inbox ORDER BY rowid") + .all() + .map((row) => JSON.parse(row.body)), count: () => db.query("SELECT count(*) AS count FROM inbox").get().count, close: () => db.close(), }; @@ -84,7 +144,11 @@ export async function deliver(provider, secret, now, post) { "SELECT * FROM outbox WHERE status = 'pending' AND next_at <= ? ORDER BY rowid", ) .all(now()); - for (const row of rows) { + for (const candidate of rows) { + const row = provider.db + .query("SELECT * FROM outbox WHERE event_id = ? AND status = 'pending'") + .get(candidate.event_id); + if (!row) continue; const timestamp = Math.floor(now() / 1000).toString(); const headers = { "content-type": WEBHOOK.contentType,