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..9b5ed75 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)); @@ -140,7 +142,7 @@ try { recordedAt: new Date().toISOString(), packageVersion: JSON.parse( readFileSync( - join(current, "node_modules/openiap-commerce-protocol/package.json"), + join(current, "node_modules/@hyodotdev/openiap-commerce-protocol/package.json"), "utf8", ), ).version, 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..e0574ba 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": "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": "06-recover" } 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..b548ab2 --- /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 "@hyodotdev/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 = { + commerceProtocolVersion: 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..2e2ee9d --- /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 "@hyodotdev/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..cbb160f --- /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 "@hyodotdev/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.commerceProtocolVersion.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..72eb7cb 100644 --- a/consumer.mjs +++ b/consumer.mjs @@ -3,11 +3,11 @@ import { randomBytes, randomUUID, createHash } from "node:crypto"; import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { WEBHOOK, COMMERCE_EVENT_VERSION } from "openiap-commerce-protocol"; +import { WEBHOOK, COMMERCE_EVENT_VERSION } from "@hyodotdev/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/contract.mjs b/contract.mjs index 6d2ca73..0fc4c5f 100644 --- a/contract.mjs +++ b/contract.mjs @@ -1,5 +1,5 @@ import Ajv from "ajv/dist/2020.js"; -import { bundleSchema, HTTP_BINDING } from "openiap-commerce-protocol"; +import { bundleSchema, HTTP_BINDING } from "@hyodotdev/openiap-commerce-protocol"; const ajv = new Ajv({ strict: false, allErrors: true }); ajv.addSchema(bundleSchema); 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/01-contract-first-attempt.txt b/docs/build/01-contract-first-attempt.txt deleted file mode 100644 index c78698b..0000000 --- a/docs/build/01-contract-first-attempt.txt +++ /dev/null @@ -1,27 +0,0 @@ - -> openiap-commerce-protocol-example@0.0.0 test -> bun verify.mjs - -36 | const checks = []; -37 | const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence }; -38 | const call = (name, input, role) => -39 | requestOperation(runtime.baseUrl, name, input, role); -40 | const check = (label, actual, expected) => { -41 | assert.deepEqual(actual, expected, label); - ^ -AssertionError: Capabilities use the published response schema - -500 !== 200 - - generatedMessage: false, - actual: 500, - expected: 200, - operator: "deepStrictEqual", - code: "ERR_ASSERTION" - - at check (/scenario.mjs:41:12) - at advance (/scenario.mjs:71:7) - at verifyLab (/verify.mjs:17:11) - at processTicksAndRejections (native:7:39) - -Bun v1.3.13 (macOS arm64) diff --git a/docs/build/01-contract/attempt-1.txt b/docs/build/01-contract/attempt-1.txt index 57a32c6..aeee5a7 100644 --- a/docs/build/01-contract/attempt-1.txt +++ b/docs/build/01-contract/attempt-1.txt @@ -1,5 +1,5 @@ { - "startedAt": "2026-09-07T12:01:16.117Z", + "startedAt": "2026-09-16T00:53:44.789Z", "command": "npm test", "passed": 6, "checks": [ diff --git a/docs/build/01-contract/changes.patch b/docs/build/01-contract/changes.patch index a814c73..485c427 100644 --- a/docs/build/01-contract/changes.patch +++ b/docs/build/01-contract/changes.patch @@ -9,6 +9,34 @@ index 0000000..680f1ea +*.sqlite* +.env* +.DS_Store +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 new file mode 100644 index 0000000..7336624 @@ -102,6 +130,158 @@ index 0000000..7336624 + +Recorded local example and reproduction instructions: +https://github.com/hyodotdev/openiap/tree/main/packages/kit/examples/commerce-protocol +diff --git a/INTEGRATE.md b/INTEGRATE.md +new file mode 100644 +index 0000000..122ee9f +--- /dev/null ++++ b/INTEGRATE.md +@@ -0,0 +1,146 @@ ++# Build your part of the OpenIAP ecosystem ++ ++Use this brief with an AI in your product repository. Pick the role you sell; ++connect the other roles to existing services. An integrated platform can own ++several roles. These are product roles, not additional protocol profiles. ++ ++| Your product | You deliver | Connect to | ++| --------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ++| Paywalls and experiments | Presentation, product selection, purchase/result callbacks | The app's existing OpenIAP purchase flow; optionally lifecycle events | ++| Commerce backend | Store verification, ownership, access, lifecycle delivery for the profiles you declare | Authenticated app backend, store adapters, downstream receivers | ++| Analytics, attribution, CRM | Durable event ingestion and your own reporting or automation | A configured commerce emitter | ++| Integrated platform | The roles above that your product supplies | One app integration with an explicit owner for each state transition | ++ ++## Start with working code ++ ++[Download the complete example](https://github.com/hyodotdev/openiap-commerce-protocol-example/archive/refs/heads/main.zip) ++and extract it into an empty directory. It contains `client-bridge.mjs`, ++`consumer.mjs`, `webhooks.mjs`, the backend, and their executable checks. ++The [example repository](https://github.com/hyodotdev/openiap-commerce-protocol-example) ++contains the same project and its build history. ++ ++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, 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. ++- `npm test` and `npm start`: verify and inspect the fixture commerce backend. ++ ++The bridge and consumer can be used separately. These checks prove local ++boundaries with fictional inputs, not a mobile checkout or a store adapter. ++The [receiver setup guide](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/receiver.md) ++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 ++vectors for the role being implemented. Package 0.1.0 does not ship `DESIGN.md`; ++the [role guide](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/INTEGRATE.md) ++and [whitepaper](https://openiap.dev/commerce-protocol-rationale.pdf) give context. ++ ++Implement the selected role using the product's existing framework and design ++system. Deliver usable code and a short connection example, not a list of work ++for the app team. Follow these boundaries: ++ ++1. **Paywall:** accept the app's store-fetched products, return a selected product ++ ID to its existing purchase callback, and display pending, canceled, failed, ++ and fulfilled results. Let the host select valid store offers. Never infer ++ purchase success or access from a click. Wire result callbacks to the host's ++ existing purchase lifecycle. Commerce Protocol defines no universal paywall ++ 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, 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 ++ the transaction through the client library. Keep store acknowledgement and ++ consumption responsibilities explicit for the chosen store and product type. ++3. **Commerce:** provide core discovery and implement every operation/obligation ++ of each advertised profile and binding. Account lifecycle includes erasure. ++ Keep one authoritative ownership and entitlement service for each app/project, ++ even when it delegates verification. Use [backend build brief](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/BUILD.md) for the backend ++ implementation sequence. The fixture backend advertises no complete profiles. ++4. **Data:** reuse or port `webhooks.mjs` and `consumer.mjs`. Authenticate exact ++ body bytes before parsing, validate, durably deduplicate in the configured ++ emitter/project scope, then acknowledge. Keep optional unknown values unknown. ++ Receiving events does not qualify a product for the `events` emitter profile. ++ Lifecycle events alone are not a revenue ledger: charge, refund, trial, tax, ++ currency conversion, attribution, and reporting policies need their own data ++ and product-specific rules. Do not count every event as a new purchase. ++ ++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. 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 ++ ++Ship the adapter/endpoint, setup command, supported stores and profiles, ++credential configuration, and one app-facing integration example. Agree on ++opaque user IDs, issuer/project scope, and versions with connected services. ++Protocol compatibility does not perform onboarding, provisioning, ownership ++migration, or provider discovery for the app automatically. ++ ++For each owned role, run a successful flow and its failure cases. Inspect the ++rendered result and actual response, fix the failing behavior, and repeat the ++same check. Save the source revision, commands, responses, screenshots, and ++limits. Include cancellation, pending purchase, duplicate delivery, expiry, ++and account isolation where applicable. An integrated platform must prove each ++role it claims; a paywall specialist need not implement a purchase verifier. ++ ++Label fixture checks separately from real device/store sandbox checks and full ++profile conformance. Never claim the latter from a schema match alone. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2987b94 @@ -201,7 +381,7 @@ index 0000000..8469ac8 +Keep all work uncommitted. diff --git a/capture.mjs b/capture.mjs new file mode 100644 -index 0000000..ed28b0c +index 0000000..5435689 --- /dev/null +++ b/capture.mjs @@ -0,0 +1,78 @@ @@ -241,7 +421,7 @@ index 0000000..ed28b0c +for (const name of files) cpSync(join(root, name), join(current, name)); +const diff = spawnSync('git', ['diff', '--no-index', '--no-ext-diff', '--', 'before', 'after'], { cwd: cache, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }); +assert([0, 1].includes(diff.status), diff.stderr); -+const patch = diff.stdout.replaceAll('a/before/', 'a/').replaceAll('b/after/', 'b/').replaceAll('a/after/', 'a/').replaceAll('b/before/', 'b/'); ++const patch = diff.stdout.split('\n').map(line => /^(diff --git |--- a\/|\+\+\+ b\/)/.test(line) ? line.replace(/([ab])\/(?:before|after)\//g, '$1/') : line).join('\n'); +writeFileSync(join(output, 'changes.patch'), patch); +const archive = spawnSync('tar', ['-czf', join(output, 'source.tar.gz'), '-C', current, ...files]); +assert.equal(archive.status, 0, archive.stderr?.toString()); @@ -266,7 +446,7 @@ index 0000000..ed28b0c + ...checkpoint, + startedAt, + recordedAt: new Date().toISOString(), -+ packageVersion: JSON.parse(readFileSync(join(root, 'node_modules/openiap-commerce-protocol/package.json'), 'utf8')).version, ++ packageVersion: JSON.parse(readFileSync(join(root, 'node_modules/@hyodotdev/openiap-commerce-protocol/package.json'), 'utf8')).version, + task: readFileSync(join(root, 'ai-task.md'), 'utf8'), + sourceHashes: Object.fromEntries(files.map(name => [name, createHash('sha256').update(readFileSync(join(current, name))).digest('hex')])), + checks, @@ -283,6 +463,342 @@ index 0000000..ed28b0c + await lab.close(); + rmSync(lab.directory, { recursive: true, force: true }); +} +diff --git a/checkpoint-tools.mjs b/checkpoint-tools.mjs +new file mode 100644 +index 0000000..8ef96bc +--- /dev/null ++++ b/checkpoint-tools.mjs +@@ -0,0 +1,188 @@ ++import assert from "node:assert/strict"; ++import { createHash } from "node:crypto"; ++import { spawnSync } from "node:child_process"; ++import { ++ cpSync, ++ existsSync, ++ mkdirSync, ++ mkdtempSync, ++ readFileSync, ++ readdirSync, ++ statSync, ++ rmSync, ++ writeFileSync, ++} from "node:fs"; ++import { tmpdir, homedir } from "node:os"; ++import { join } from "node:path"; ++ ++export const SOURCE_FILES = [ ++ ".gitignore", ++ ".yarnrc.yml", ++ "LICENSE", ++ "AGENTS.md", ++ "README.md", ++ "BUILD.md", ++ "INTEGRATE.md", ++ "ai-task.md", ++ "checkpoint.json", ++ "package.json", ++ "package-lock.json", ++ "contract.mjs", ++ "provider.mjs", ++ "erasure.mjs", ++ "verify-erasure.mjs", ++ "verify-stores.mjs", ++ "webhooks.mjs", ++ "consumer.mjs", ++ "client-bridge.mjs", ++ "scenario.mjs", ++ "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", ++ "checkpoint-tools.test.mjs", ++ "verify-checkpoints.mjs", ++]; ++export const sha256 = (file) => ++ createHash("sha256").update(readFileSync(file)).digest("hex"); ++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) ++ .sort((a, b) => b.length - a.length); ++ let result = text; ++ for (const root of roots) result = result.replaceAll(root, ""); ++ return result.replace( ++ /\/[^\s"']*\/commerce-(?:capture|patch|verify|lab|example|compare|consumer)-[^\s/"']+/g, ++ "", ++ ); ++} ++export function nextAttempt(directory) { ++ return ( ++ Math.max( ++ 0, ++ ...readdirSync(directory).map((name) => ++ Number(/^(?:attempt|failure)-(\d+)\.txt$/.exec(name)?.[1] ?? 0), ++ ), ++ ) + 1 ++ ); ++} ++export function run(command, args, cwd) { ++ const result = spawnSync(command, args, { ++ cwd, ++ encoding: "utf8", ++ maxBuffer: 20 * 1024 * 1024, ++ }); ++ const output = sanitize((result.stdout ?? "") + (result.stderr ?? "")); ++ assert.equal(result.status, 0, `${command} ${args.join(" ")}\n${output}`); ++ return { ++ command: [command, ...args].join(" "), ++ exitCode: result.status, ++ output, ++ }; ++} ++export function extract(archive, target) { ++ mkdirSync(target, { recursive: true }); ++ // Older macOS archives contain AppleDouble metadata, not source files. ++ run("tar", [ ++ ...(process.platform === "darwin" ? ["--no-mac-metadata"] : []), ++ "--exclude=._*", ++ "-xzf", ++ archive, ++ "-C", ++ target, ++ ]); ++} ++export function normalizePatch(patch) { ++ return patch ++ .split("\n") ++ .map((line) => { ++ if (/^(diff --git |--- a\/|\+\+\+ b\/)/.test(line)) { ++ return line.replace(/([ab])\/(?:before|after)\//g, "$1/"); ++ } ++ return line; ++ }) ++ .join("\n"); ++} ++export function createPatch(before, after) { ++ const temp = mkdtempSync(join(tmpdir(), "commerce-patch-")); ++ try { ++ cpSync(before, join(temp, "before"), { recursive: true }); ++ cpSync(after, join(temp, "after"), { recursive: true }); ++ const result = spawnSync( ++ "git", ++ ["diff", "--no-index", "--no-ext-diff", "--", "before", "after"], ++ { cwd: temp, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }, ++ ); ++ assert([0, 1].includes(result.status), result.stderr); ++ const patch = normalizePatch(result.stdout); ++ writeFileSync(join(temp, "changes.patch"), patch); ++ cpSync(before, join(temp, "applied"), { recursive: true }); ++ if (patch) ++ run("git", ["apply", join(temp, "changes.patch")], join(temp, "applied")); ++ assert.deepEqual( ++ hashes(join(temp, "applied")), ++ hashes(after), ++ "Patch must reproduce the source archive exactly", ++ ); ++ return patch; ++ } finally { ++ rmSync(temp, { recursive: true, force: true }); ++ } ++} ++export function readRecords(directory) { ++ const records = readdirSync(directory) ++ .filter( ++ (name) => ++ /^\d\d-[\w-]+$/.test(name) && ++ existsSync(join(directory, name, "run.json")), ++ ) ++ .sort() ++ .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 +new file mode 100644 +index 0000000..a45e857 +--- /dev/null ++++ b/checkpoint-tools.test.mjs +@@ -0,0 +1,136 @@ ++import assert from "node:assert/strict"; ++import { test } from "bun:test"; ++import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; ++import { tmpdir, homedir } from "node:os"; ++import { join } from "node:path"; ++import { ++ createPatch, ++ extract, ++ hashes, ++ nextAttempt, ++ run, ++ sanitize, ++ readRecords, ++} from "./checkpoint-tools.mjs"; ++ ++test("archive extraction excludes macOS metadata and preserves source hashes", () => { ++ const temp = mkdtempSync(join(tmpdir(), "commerce-capture-test-")); ++ try { ++ const source = join(temp, "source"); ++ mkdirSync(source); ++ writeFileSync(join(source, "README.md"), "Source content\n"); ++ const expected = hashes(source); ++ const metadata = Buffer.alloc(70); ++ metadata.writeUInt32BE(0x00051607, 0); ++ metadata.writeUInt32BE(0x00020000, 4); ++ metadata.writeUInt16BE(1, 24); ++ metadata.writeUInt32BE(9, 26); ++ metadata.writeUInt32BE(38, 30); ++ metadata.writeUInt32BE(32, 34); ++ writeFileSync(join(source, "._README.md"), metadata); ++ const archive = join(temp, "source.tar.gz"); ++ run("env", [ ++ "COPYFILE_DISABLE=1", ++ "tar", ++ "-czf", ++ archive, ++ "-C", ++ source, ++ "._README.md", ++ "README.md", ++ ]); ++ const target = join(temp, "extracted"); ++ extract(archive, target); ++ assert.deepEqual(hashes(target), expected); ++ } finally { ++ rmSync(temp, { recursive: true, force: true }); ++ } ++}); ++ ++test("patches preserve path-like source text across additions, changes, and deletions", () => { ++ const temp = mkdtempSync(join(tmpdir(), "commerce-patch-test-")); ++ try { ++ const before = join(temp, "before"), ++ after = join(temp, "after"); ++ mkdirSync(before); ++ mkdirSync(after); ++ writeFileSync( ++ join(after, "capture.mjs"), ++ "const paths = ['a/before/', 'b/after/'];\n", ++ ); ++ assert.match( ++ createPatch(before, after), ++ /\+const paths = \['a\/before\/', 'b\/after\/'\];/, ++ ); ++ writeFileSync(join(before, "capture.mjs"), "old\n"); ++ writeFileSync(join(before, "removed.md"), "remove\n"); ++ assert.match(createPatch(before, after), /deleted file mode/); ++ } finally { ++ rmSync(temp, { recursive: true, force: true }); ++ } ++}); ++ ++test("failed attempts get new numbers and private paths are redacted", () => { ++ const temp = mkdtempSync(join(tmpdir(), "commerce-capture-test-")); ++ try { ++ writeFileSync(join(temp, "failure-1.txt"), "first failure"); ++ assert.equal(nextAttempt(temp), 2); ++ writeFileSync(join(temp, "failure-2.txt"), "second failure"); ++ assert.equal(nextAttempt(temp), 3); ++ const output = sanitize( ++ `${homedir()}/.npm/_logs/debug.log ${import.meta.dir}/capture.mjs ${temp}/source.mjs`, ++ ); ++ assert(!output.includes(homedir())); ++ assert(!output.includes(import.meta.dir)); ++ assert(!output.includes(temp)); ++ } finally { ++ rmSync(temp, { recursive: true, force: true }); ++ } ++}); ++ ++test("unfinished captures do not hide completed records", () => { ++ const temp = mkdtempSync(join(tmpdir(), "commerce-capture-test-")); ++ try { ++ mkdirSync(join(temp, "01-complete")); ++ writeFileSync( ++ join(temp, "01-complete/run.json"), ++ JSON.stringify({ id: "01-complete" }), ++ ); ++ mkdirSync(join(temp, "02-failed")); ++ writeFileSync(join(temp, "02-failed/failure-1.txt"), "Browser unavailable"); ++ assert.deepEqual(readRecords(temp), [{ id: "01-complete" }]); ++ writeFileSync( ++ join(temp, "02-failed/run.json"), ++ JSON.stringify({ id: "02-failed" }), ++ ); ++ assert.equal(readRecords(temp).length, 2); ++ } finally { ++ 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 new file mode 100644 index 0000000..04e22f5 @@ -296,14 +812,367 @@ index 0000000..04e22f5 + "built": "HTTP routes + schema validation + SQLite", + "result": "A running server, an empty purchase table, and no access." +} +diff --git a/client-bridge.mjs b/client-bridge.mjs +new file mode 100644 +index 0000000..248d314 +--- /dev/null ++++ b/client-bridge.mjs +@@ -0,0 +1,130 @@ ++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, context = {}) { ++ const { store, purchaseToken } = purchase ?? {}; ++ 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; ++} ++ ++export function runBridgeDemo() { ++ const checks = []; ++ for (const store of ["apple", "google"]) { ++ const token = `fictional-${store}-evidence`; ++ const input = toVerifyPurchaseInput({ ++ store, ++ purchaseToken: token, ++ productId: "premium.monthly", ++ userId: "untrusted-client-claim", ++ }); ++ assert.deepEqual(input, { ++ store, ++ ...(store === "apple" ++ ? { apple: { jws: token } } ++ : { google: { purchaseToken: token } }), ++ }); ++ checks.push(`${store}: maps evidence without forwarding client identity`); ++ 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 requires its authenticated store user", ++ { store: "amazon", purchaseToken: "fictional" }, ++ ], ++ [ ++ "Horizon requires its authenticated store user", ++ { store: "horizon", purchaseToken: "fictional" }, ++ ], ++ ["missing evidence", { store: "apple" }], ++ ["blank evidence", { store: "google", purchaseToken: " " }], ++ ["non-string evidence", { store: "apple", purchaseToken: 123 }], ++ [ ++ "oversized evidence", ++ { store: "apple", purchaseToken: "x".repeat(16385) }, ++ ], ++ ]) { ++ assert.throws(() => toVerifyPurchaseInput(purchase), Error); ++ checks.push(`Rejects ${label}`); ++ } ++ return checks; ++} ++ ++if (import.meta.main) ++ console.log( ++ `Client boundary: ${runBridgeDemo().length} checks passed against the installed contract. Fixture fields only; no SDK checkout or store contacted.`, ++ ); +diff --git a/consumer.mjs b/consumer.mjs +new file mode 100644 +index 0000000..72eb7cb +--- /dev/null ++++ b/consumer.mjs +@@ -0,0 +1,211 @@ ++import assert from "node:assert/strict"; ++import { randomBytes, randomUUID, createHash } from "node:crypto"; ++import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; ++import { tmpdir } from "node:os"; ++import { join, resolve } from "node:path"; ++import { WEBHOOK, COMMERCE_EVENT_VERSION } from "@hyodotdev/openiap-commerce-protocol"; ++import { createReceiver, sign } from "./webhooks.mjs"; ++import { validate } from "./contract.mjs"; ++ ++// 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); ++ const server = Bun.serve({ ++ hostname: "127.0.0.1", ++ port, ++ maxRequestBodySize: 64 * 1024, ++ fetch(request) { ++ const url = new URL(request.url); ++ if (request.method === "POST" && url.pathname === "/webhooks/commerce") ++ return receiver.fetch(request); ++ if (request.method === "GET" && url.pathname === "/health") ++ return Response.json({ ready: true }); ++ return new Response("Not found", { status: 404 }); ++ }, ++ }); ++ 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); ++ }, ++ async close() { ++ await server.stop(true); ++ receiver.close(); ++ }, ++ }; ++} ++ ++export async function runConsumerDemo() { ++ const directory = mkdtempSync(join(tmpdir(), "commerce-consumer-")); ++ const secret = randomBytes(32).toString("hex"); ++ const now = Date.now(); ++ const consumer = startConsumer({ ++ secret, ++ path: join(directory, "inbox.sqlite"), ++ now: () => now, ++ }); ++ const checks = [], ++ results = []; ++ const check = (name, actual, expected) => { ++ assert.deepEqual(actual, expected, name); ++ checks.push(name); ++ }; ++ const lifecycle = [ ++ ["subscription.started", "Active", true], ++ ["entitlement.granted", "Active", true], ++ ["subscription.renewed", "Active", true], ++ ["subscription.canceled", "Active", true], ++ ["subscription.expired", "Expired", false], ++ ["entitlement.revoked", "Expired", false], ++ ["subscription.refunded", "Refunded", false], ++ ]; ++ try { ++ const health = await fetch(new URL("/health", consumer.url), { ++ headers: { host: "receiver.example.test" }, ++ }); ++ check("Health accepts the proxy public Host header", health.status, 200); ++ for (const [index, [eventType, state, active]] of lifecycle.entries()) { ++ const event = { ++ eventId: randomUUID(), ++ eventType, ++ eventVersion: COMMERCE_EVENT_VERSION, ++ occurredAt: now - 60_000 + index * 1000, ++ processedAt: now, ++ store: "fixture", ++ environment: "local-fixture", ++ projectId: "demo_project", ++ userId: "demo_user", ++ productId: "premium.monthly", ++ ...(["subscription.started", "subscription.renewed"].includes(eventType) ++ ? { ++ price: { ++ currency: "USD", ++ amountMicros: 9990000, ++ provenance: "store", ++ }, ++ } ++ : {}), ++ subscription: { ++ productId: "premium.monthly", ++ state, ++ active, ++ expiresAt: active ? now + 60_000 : now - 1000, ++ willRenew: active && eventType !== "subscription.canceled", ++ }, ++ }; ++ assert(validate("#/$defs/CommerceEvent", event)); ++ const body = JSON.stringify(event); ++ const timestamp = String(Math.floor(now / 1000)); ++ const headers = { ++ host: "receiver.example.test", ++ "content-type": WEBHOOK.contentType, ++ [WEBHOOK.timestampHeader]: timestamp, ++ [WEBHOOK.signatureHeader]: sign(secret, timestamp, body), ++ [WEBHOOK.eventIdHeader]: event.eventId, ++ [WEBHOOK.deliveryIdHeader]: randomUUID(), ++ }; ++ const response = await fetch(consumer.url, { ++ method: "POST", ++ headers, ++ body, ++ }); ++ const result = await response.json(); ++ check( ++ `${eventType}: authenticated and saved`, ++ [response.status, result.duplicate], ++ [200, false], ++ ); ++ const duplicate = await fetch(consumer.url, { ++ method: "POST", ++ headers, ++ body, ++ }); ++ const duplicateResult = await duplicate.json(); ++ check( ++ `${eventType}: redelivery deduplicated`, ++ [duplicate.status, duplicateResult.duplicate], ++ [200, true], ++ ); ++ const tampered = await fetch(consumer.url, { ++ method: "POST", ++ headers, ++ body: body + " ", ++ }); ++ check(`${eventType}: tampering rejected`, tampered.status, 401); ++ results.push({ ++ event, ++ httpStatus: response.status, ++ duplicate: duplicateResult.duplicate, ++ requestHost: headers.host, ++ tamperedHttpStatus: tampered.status, ++ }); ++ } ++ check("One inbox record per event", consumer.count(), lifecycle.length); ++ consumer.reopen(); ++ check( ++ "Inbox survives reopening SQLite", ++ consumer.count(), ++ lifecycle.length, ++ ); ++ return { ++ recordedAt: new Date().toISOString(), ++ 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, ++ results, ++ inboxCount: consumer.count(), ++ }; ++ } finally { ++ await consumer.close(); ++ rmSync(directory, { recursive: true, force: true }); ++ } ++} ++ ++if (import.meta.main) { ++ if (process.argv[2] === "demo") { ++ const report = await runConsumerDemo(); ++ if (process.argv[3] === "--record") { ++ assert(process.argv[4], "Provide a report path"); ++ report.sourceHashes = Object.fromEntries( ++ [ ++ "consumer.mjs", ++ "webhooks.mjs", ++ "contract.mjs", ++ "package.json", ++ "package-lock.json", ++ ].map((name) => [ ++ name, ++ createHash("sha256") ++ .update(readFileSync(join(import.meta.dir, name))) ++ .digest("hex"), ++ ]), ++ ); ++ writeFileSync( ++ resolve(process.argv[4]), ++ JSON.stringify(report, null, 2) + "\n", ++ ); ++ } ++ console.log( ++ `Receiver ready: ${report.inboxCount} events saved once; ${report.checks.length} checks passed. No external service contacted.`, ++ ); ++ } else { ++ const consumer = startConsumer({ ++ secret: process.env.COMMERCE_WEBHOOK_SECRET, ++ path: resolve(process.env.COMMERCE_INBOX_PATH ?? "consumer.sqlite"), ++ port: 5182, ++ }); ++ console.log( ++ `Receiver: ${consumer.url}\nPersisted inbox: ${resolve(process.env.COMMERCE_INBOX_PATH ?? "consumer.sqlite")}`, ++ ); ++ for (const signal of ["SIGINT", "SIGTERM"]) ++ process.on(signal, async () => { ++ await consumer.close(); ++ process.exit(0); ++ }); ++ } ++} diff --git a/contract.mjs b/contract.mjs new file mode 100644 -index 0000000..6d2ca73 +index 0000000..0fc4c5f --- /dev/null +++ b/contract.mjs @@ -0,0 +1,24 @@ +import Ajv from "ajv/dist/2020.js"; -+import { bundleSchema, HTTP_BINDING } from "openiap-commerce-protocol"; ++import { bundleSchema, HTTP_BINDING } from "@hyodotdev/openiap-commerce-protocol"; + +const ajv = new Ajv({ strict: false, allErrors: true }); +ajv.addSchema(bundleSchema); @@ -716,9 +1585,179 @@ index 0000000..85b613b + + + +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 +new file mode 100644 +index 0000000..4dbddd0 +--- /dev/null ++++ b/export-docs.mjs +@@ -0,0 +1,122 @@ ++import assert from "node:assert/strict"; ++import { ++ cpSync, ++ mkdirSync, ++ mkdtempSync, ++ readFileSync, ++ rmSync, ++ writeFileSync, ++} from "node:fs"; ++import { tmpdir } from "node:os"; ++import { join, resolve } from "node:path"; ++import { ++ SOURCE_FILES, ++ extract, ++ hashes, ++ readRecords, ++ sha256, ++} from "./checkpoint-tools.mjs"; ++ ++const target = process.argv[2]; ++assert(target, "Usage: bun export-docs.mjs "); ++const output = resolve(target); ++const source = join(import.meta.dir, "docs/build"); ++const guide = JSON.parse(readFileSync(join(source, "guide.json"), "utf8")); ++const records = readRecords(source); ++const selected = guide.map((step) => { ++ const record = records.find((record) => record.id === step.id); ++ assert(record, `Missing checkpoint: ${step.id}`); ++ return record; ++}); ++const last = selected.at(-1); ++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) => [ ++ name, ++ sha256(join(import.meta.dir, name)), ++ ]), ++ ), ++ last.sourceHashes, ++ "Capture the final source before exporting", ++); ++const verification = JSON.parse( ++ readFileSync(join(source, "verification.json"), "utf8"), ++); ++for (const record of records) { ++ const result = verification.results.find((result) => result.id === record.id); ++ assert( ++ result?.sourceHashesMatch && result.patchAppliesExactly, ++ `Verify ${record.id} before export`, ++ ); ++ assert.equal( ++ result.archiveSha256, ++ sha256(join(source, record.id, "source.tar.gz")), ++ ); ++ assert.equal( ++ result.patchSha256, ++ sha256(join(source, record.id, "changes.patch")), ++ ); ++} ++const consumerReport = JSON.parse( ++ readFileSync(join(source, "consumer-run.json"), "utf8"), ++); ++for (const [name, digest] of Object.entries(consumerReport.sourceHashes)) ++ assert.equal( ++ digest, ++ last.sourceHashes[name], ++ "Record the consumer demo again before export", ++ ); ++mkdirSync(output, { recursive: true }); ++cpSync(join(source, last.id, "source.tar.gz"), join(output, "source.tar.gz")); ++for (const record of records) ++ cpSync(join(source, record.id), join(output, record.id), { recursive: true }); ++for (const name of [ ++ "README.md", ++ "REVIEW.md", ++ "verification.json", ++ "consumer-run.json", ++ "01-contract-first-attempt.txt", ++]) ++ cpSync(join(source, name), join(output, name)); ++const report = { ++ recordedAt: last.recordedAt, ++ scope: last.scope, ++ checks: last.checks, ++ standalone: { version: last.packageVersion, passed: last.checks.length }, ++ milestones: selected.map((record, index) => ({ ++ ...record.history.at(-1), ++ screenshot: `${record.id}/screen.png`, ++ build: { ++ ...guide[index], ++ source: `${record.id}/source.tar.gz`, ++ changes: `${record.id}/changes.patch`, ++ report: `${record.id}/run.json`, ++ ...(record.previous ++ ? { previousSource: `${record.previous}/source.tar.gz` } ++ : {}), ++ passed: record.checks.length, ++ }, ++ })), ++}; ++const temp = mkdtempSync(join(tmpdir(), "commerce-verify-export-")); ++try { ++ extract(join(source, last.id, "source.tar.gz"), temp); ++ assert.deepEqual(hashes(temp), last.sourceHashes); ++ cpSync(join(temp, "BUILD.md"), join(output, "build-brief.md")); ++ cpSync(join(temp, "INTEGRATE.md"), join(output, "integration-brief.md")); ++} finally { ++ rmSync(temp, { recursive: true, force: true }); ++} ++writeFileSync(join(output, "run.json"), JSON.stringify(report, null, 2) + "\n"); ++console.log( ++ `Exported ${selected.length} milestones and ${records.length} source revisions to ${output}`, ++); diff --git a/package-lock.json b/package-lock.json new file mode 100644 -index 0000000..0583924 +index 0000000..c5800f3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,141 @@ @@ -732,13 +1771,19 @@ index 0000000..0583924 + "name": "openiap-commerce-protocol-example", + "version": "0.0.0", + "dependencies": { -+ "ajv": "^8.17.1", -+ "openiap-commerce-protocol": "0.1.0" ++ "@hyodotdev/openiap-commerce-protocol": "0.3.0", ++ "ajv": "^8.17.1" + }, + "devDependencies": { + "@playwright/test": "1.62.1" + } + }, ++ "node_modules/@hyodotdev/openiap-commerce-protocol": { ++ "version": "0.3.0", ++ "resolved": "https://registry.npmjs.org/@hyodotdev/openiap-commerce-protocol/-/openiap-commerce-protocol-0.3.0.tgz", ++ "integrity": "sha512-IHm1uewLOrIIyud8wG2YQFd6YplUQIMMCAV68BmNOOVRKQBgRjr/HANLXZAChyK33MHgn0LKVoLxO6ukJQi+Ig==", ++ "license": "MIT" ++ }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", @@ -778,9 +1823,9 @@ index 0000000..0583924 + "license": "MIT" + }, + "node_modules/fast-uri": { -+ "version": "3.1.7", -+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", -+ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", ++ "version": "3.1.8", ++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", ++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", @@ -814,12 +1859,6 @@ index 0000000..0583924 + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, -+ "node_modules/openiap-commerce-protocol": { -+ "version": "0.1.0", -+ "resolved": "https://registry.npmjs.org/openiap-commerce-protocol/-/openiap-commerce-protocol-0.1.0.tgz", -+ "integrity": "sha512-z92P3JeMK0Hj2y227ibDYKz1qJJC0JuwRUqm2mi6slhQoBHYTldXYIExHDtl7Q3SbqGmPqpBsTWlTwVRFymSjA==", -+ "license": "MIT" -+ }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", @@ -865,7 +1904,7 @@ index 0000000..0583924 +} diff --git a/package.json b/package.json new file mode 100644 -index 0000000..ca0864e +index 0000000..79f3dea --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ @@ -880,7 +1919,7 @@ index 0000000..ca0864e + "capture": "bun capture.mjs" + }, + "dependencies": { -+ "openiap-commerce-protocol": "0.1.0", ++ "@hyodotdev/openiap-commerce-protocol": "0.3.0", + "ajv": "^8.17.1" + }, + "devDependencies": { @@ -889,7 +1928,7 @@ index 0000000..ca0864e +} diff --git a/provider.mjs b/provider.mjs new file mode 100644 -index 0000000..3d358af +index 0000000..0bdbcd9 --- /dev/null +++ b/provider.mjs @@ -0,0 +1,121 @@ @@ -899,7 +1938,7 @@ index 0000000..3d358af + providerCapabilitiesSchema, + COMMERCE_EVENT_VERSION, + HTTP_BINDING, -+} from "openiap-commerce-protocol"; ++} from "@hyodotdev/openiap-commerce-protocol"; +import { protocolError, validate } from "./contract.mjs"; + +export const FIXTURE = Object.freeze({ @@ -1191,6 +2230,434 @@ index 0000000..fbac7e0 + process.exit(0); + }); +} +diff --git a/verify-checkpoints.mjs b/verify-checkpoints.mjs +new file mode 100644 +index 0000000..bd6b925 +--- /dev/null ++++ b/verify-checkpoints.mjs +@@ -0,0 +1,84 @@ ++import assert from "node:assert/strict"; ++import { ++ mkdirSync, ++ mkdtempSync, ++ readFileSync, ++ rmSync, ++ writeFileSync, ++} from "node:fs"; ++import { tmpdir } from "node:os"; ++import { join, resolve } from "node:path"; ++import { ++ extract, ++ hashes, ++ readRecords, ++ run, ++ sha256, ++} from "./checkpoint-tools.mjs"; ++ ++const source = resolve(process.argv[2] ?? join(import.meta.dir, "docs/build")); ++const temp = mkdtempSync(join(tmpdir(), "commerce-verify-")); ++const records = readRecords(source); ++const results = []; ++try { ++ const applied = join(temp, "applied"); ++ mkdirSync(applied); ++ for (const record of records) { ++ const directory = join(source, record.id); ++ const archive = join(directory, "source.tar.gz"); ++ const consumer = join(temp, record.id); ++ extract(archive, consumer); ++ assert.deepEqual( ++ hashes(consumer), ++ record.sourceHashes, ++ `${record.id}: archive hashes`, ++ ); ++ const patch = join(directory, "changes.patch"); ++ if (readFileSync(patch, "utf8")) run("git", ["apply", patch], applied); ++ assert.deepEqual( ++ hashes(applied), ++ record.sourceHashes, ++ `${record.id}: patch chain from empty`, ++ ); ++ const commands = [ ++ run( ++ "npm", ++ ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], ++ consumer, ++ ), ++ run("npm", ["test"], consumer), ++ ]; ++ if ( ++ JSON.parse(readFileSync(join(consumer, "package.json"), "utf8")).scripts[ ++ "test:tooling" ++ ] ++ ) ++ commands.push(run("npm", ["run", "test:tooling"], consumer)); ++ results.push({ ++ id: record.id, ++ archiveSha256: sha256(archive), ++ patchSha256: sha256(patch), ++ sourceHashesMatch: true, ++ patchAppliesExactly: true, ++ commands, ++ }); ++ console.log( ++ `Verified ${record.id}: extracted source, patch chain, npm ci, npm test`, ++ ); ++ } ++ writeFileSync( ++ join(source, "verification.json"), ++ JSON.stringify( ++ { ++ recordedAt: new Date().toISOString(), ++ scope: ++ "Each recorded archive extracted outside both repositories; patches applied in order from an empty directory; published dependencies installed with npm.", ++ results, ++ }, ++ null, ++ 2, ++ ) + "\n", ++ ); ++} finally { ++ rmSync(temp, { recursive: true, force: true }); ++} +diff --git a/verify-erasure.mjs b/verify-erasure.mjs +new file mode 100644 +index 0000000..d8ef01e +--- /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 "@hyodotdev/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 new file mode 100644 index 0000000..e23c995 @@ -1226,3 +2693,197 @@ index 0000000..e23c995 + const checks = await verifyLab(); + console.log(JSON.stringify({passed: checks.length, checks}, null, 2)); +} +diff --git a/webhooks.mjs b/webhooks.mjs +new file mode 100644 +index 0000000..df8523d +--- /dev/null ++++ b/webhooks.mjs +@@ -0,0 +1,188 @@ ++import { Database } from "bun:sqlite"; ++import { createHmac, timingSafeEqual } from "node:crypto"; ++import { WEBHOOK } from "@hyodotdev/openiap-commerce-protocol"; ++import { validate } from "./contract.mjs"; ++import { createErasureLedger } from "./erasure.mjs"; ++ ++export function sign(secret, timestamp, body) { ++ return ( ++ WEBHOOK.signaturePrefix + ++ createHmac("sha256", secret) ++ .update(`${timestamp}.`) ++ .update(body) ++ .digest("hex") ++ ); ++} ++ ++export function authentic(secrets, timestamp, body, signatures, nowSeconds) { ++ if (!/^\d+$/.test(timestamp ?? "")) return false; ++ if ( ++ !Number.isSafeInteger(Number(timestamp)) || ++ Math.abs(nowSeconds - Number(timestamp)) > WEBHOOK.toleranceSeconds ++ ) ++ return false; ++ return (signatures ?? "").split(",").some((raw) => { ++ const candidate = raw.trim(); ++ if (!/^v1=[a-f0-9]{64}$/.test(candidate)) return false; ++ return secrets.some((secret) => ++ timingSafeEqual( ++ Buffer.from(candidate), ++ Buffer.from(sign(secret, timestamp, body)), ++ ), ++ ); ++ }); ++} ++ ++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()); ++ 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; ++ try { ++ body = new TextDecoder("utf-8", { fatal: true }).decode(bytes); ++ event = JSON.parse(body); ++ } catch { ++ return new Response("Invalid JSON", { status: 400 }); ++ } ++ if (!validate("#/$defs/CommerceEvent", event)) ++ 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( ++ 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(), ++ }; ++} ++ ++// The caller injects a loopback test transport. This is not a public HTTPS worker. ++export async function deliver(provider, secret, now, post) { ++ const results = []; ++ const rows = provider.db ++ .query( ++ "SELECT * FROM outbox WHERE status = 'pending' AND next_at <= ? ORDER BY rowid", ++ ) ++ .all(now()); ++ 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, ++ [WEBHOOK.timestampHeader]: timestamp, ++ [WEBHOOK.signatureHeader]: sign(secret, timestamp, row.body), ++ [WEBHOOK.eventIdHeader]: row.event_id, ++ [WEBHOOK.deliveryIdHeader]: row.delivery_id, ++ }; ++ let status; ++ try { ++ status = (await post({ method: "POST", headers, body: row.body })).status; ++ } catch { ++ status = 503; ++ } ++ const attempts = row.attempts + 1; ++ const retryable = status === 408 || status === 429 || status >= 500; ++ const next = ++ status >= 200 && status < 300 ++ ? "delivered" ++ : retryable && attempts < 3 ++ ? "pending" ++ : "dead-letter"; ++ provider.db ++ .query( ++ "UPDATE outbox SET attempts = ?, status = ?, next_at = ? WHERE event_id = ?", ++ ) ++ .run(attempts, next, now() + 30_000 * 2 ** (attempts - 1), row.event_id); ++ results.push({ ++ eventId: row.event_id, ++ deliveryId: row.delivery_id, ++ httpStatus: status, ++ attempt: attempts, ++ status: next, ++ }); ++ } ++ return results; ++} diff --git a/docs/build/01-contract/run.json b/docs/build/01-contract/run.json index 9282fb0..e2c21c1 100644 --- a/docs/build/01-contract/run.json +++ b/docs/build/01-contract/run.json @@ -4,26 +4,38 @@ "title": "Start with the contract", "built": "HTTP routes + schema validation + SQLite", "result": "A running server, an empty purchase table, and no access.", - "startedAt": "2026-09-07T12:01:16.117Z", - "recordedAt": "2026-09-07T12:01:18.838Z", - "packageVersion": "0.1.0", + "startedAt": "2026-09-16T00:53:44.789Z", + "recordedAt": "2026-09-16T00:53:45.940Z", + "packageVersion": "0.3.0", "task": "# Start with the contract\n\nInstall the published contract in an independent project. Add capabilities, request/response validation, local HTTP inspection, and SQLite. Return UNSUPPORTED_PROFILE for operations that have not been implemented. Do not claim profile conformance.\n\nUse the existing reviewed prototype where it satisfies the installed contract.\nRun the backend, inspect the result, correct problems, and rerun the affected check.\nKeep all work uncommitted.\n", "sourceHashes": { + "verify-erasure.mjs": "5d6864387941a103d1f92b441eb34c96a50026068ead13c8b669ba06f7b7be4f", "checkpoint.json": "c0e3e63c3abbb62fb394f7b0cb7fdb6a995203bf1e5cc35a7c126e3655e9e8e0", "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", - "provider.mjs": "0324c016d3e5a750a4e096831f1139bfe14a55ac0b0f1c8bb478cb88d7c11bd8", - "capture.mjs": "db84e47044c19bb806f74cdbd79492a804a67f28b2942446e7d85b08dbf9b94d", + "export-docs.mjs": "47cf25c7ba1942fb833ac6b496c1e06b4ae06a61f6328330c6a6a4337c164f84", + "erasure.mjs": "2dddb3ffd929c9d05333e88193f7cb3e5874ef5b29e68c68a1b25997d09788ea", + "client-bridge.mjs": "a6760f1a9059e650fa19ef96403b16f7860390fce390262276020bd2cc7d5078", + "provider.mjs": "10eb75d5a25aa23bd685388412979f8c3dcb1344952b23efa0343382d98500f2", + "capture.mjs": "a6e95d6708fb9965a6b7c6d8bc1b6590f2c208c6a32a5a0029a05cfea702469b", "ai-task.md": "8ca2446238c9c3c47f1f3c30dbb820ccba361e5aff7153b265fbf6b582146452", - "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", + "contract.mjs": "474dfee661cf00617872f6f50c3f4f5e88706e80d2fd6fcdda9304f489268c73", "dashboard.html": "a375bc19ff6e419a4dedd8680d0baf50d8a5336cfe2e0ba4036463329fd8e076", "verify.mjs": "4428a2391ed57038840ee34320406e294f0e01fdb9e097ffc09de00e987c93d5", "README.md": "ea83c0f24a18005c4e86aa3a951f4a7ac0f605537a7aa0e8fe2a179564a25771", + "verify-stores.mjs": "6a9ea6071aebddac158435b8577f4b31914d902191f6cc1476683ea6d9bac3dd", ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", - "package-lock.json": "7b7e6d588b495fd74020864325d8004ad797fb6b7fc563fc373f07c5c2501479", - "package.json": "191b82f635869339c5ae0ec9071115deba3813a73cc6060d99899b9290e38731", + "package-lock.json": "ef250412311d92295bed891735036a0d9bec1f8a24774316573f13699ab71cec", + "package.json": "a32429c9bb82d7f9cb1ea6ed4f022d46eea6364477572e63f7cc4a854b799ec2", + "consumer.mjs": "f9cdc5eff17f322dd2ae146546cfc5ed9b5f1a02495294b83bc17ec227aff18f", "BUILD.md": "a21280d90624e644da240d309b726bc8c7d07c1eddcafe7e3c2333d8451e6643", + "webhooks.mjs": "e6da4eb939244f1b05ad61307f717a2214bf830da9d27993beb969676cc936a6", "server.mjs": "3b4813c7907026488b29c163a8a2ce066402fe0bc2336e126c709e582af7a455", - "scenario.mjs": "eef892092968a732ba648a0d1a77b66a856d9b341608f82d42c9257b9f4f6d6c" + "scenario.mjs": "eef892092968a732ba648a0d1a77b66a856d9b341608f82d42c9257b9f4f6d6c", + "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", + "checkpoint-tools.mjs": "137fb2798a7e73e2db9a716d8cd3ea78fe3155ab0651ecae39f1e629634ec19c", + "AGENTS.md": "38764ea47552533ab5db55f7ade8c0025e2d7af9163715ce89f2d719f5475712", + "INTEGRATE.md": "ed3851d6432deedb85d2d0cc3edcca8fe52f58c79c99b68a9aa54616b010949b", + "checkpoint-tools.test.mjs": "1ea348330db8dab85694ac79cfec76e9d3af1729263a6a05cae61e10e41b2175" }, "checks": [ "Fixture request matches the installed schema", diff --git a/docs/build/01-contract/source.tar.gz b/docs/build/01-contract/source.tar.gz index 26217ee..217d075 100644 Binary files a/docs/build/01-contract/source.tar.gz and b/docs/build/01-contract/source.tar.gz differ diff --git a/docs/build/02-verify/attempt-1.txt b/docs/build/02-verify/attempt-1.txt index 790139a..3fa0d94 100644 --- a/docs/build/02-verify/attempt-1.txt +++ b/docs/build/02-verify/attempt-1.txt @@ -1,5 +1,5 @@ { - "startedAt": "2026-09-07T12:06:43.488Z", + "startedAt": "2026-09-16T00:53:52.557Z", "command": "npm test", "passed": 10, "checks": [ diff --git a/docs/build/02-verify/changes.patch b/docs/build/02-verify/changes.patch index 3ed61a0..34c14cb 100644 --- a/docs/build/02-verify/changes.patch +++ b/docs/build/02-verify/changes.patch @@ -12,10 +12,10 @@ index 8469ac8..239ce5c 100644 Use the existing reviewed prototype where it satisfies the installed contract. Run the backend, inspect the result, correct problems, and rerun the affected check. diff --git a/checkpoint.json b/checkpoint.json -index 04e22f5..9b6e1f2 100644 +index 04e22f5..5053480 100644 --- a/checkpoint.json +++ b/checkpoint.json -@@ -1,7 +1,7 @@ +@@ -1,7 +1,8 @@ { - "step": 1, - "id": "01-contract", @@ -26,10 +26,11 @@ index 04e22f5..9b6e1f2 100644 + "id": "02-verify", + "title": "Verify a purchase", + "built": "Fixture store adapter + purchase persistence", -+ "result": "Valid evidence is saved. Alice still has no access." ++ "result": "Valid evidence is saved. Alice still has no access.", ++ "previous": "01-contract" } diff --git a/provider.mjs b/provider.mjs -index 3d358af..c90643a 100644 +index 0bdbcd9..08c4ada 100644 --- a/provider.mjs +++ b/provider.mjs @@ -28,6 +28,16 @@ export function isEntitled(state, expiresAt, now) { diff --git a/docs/build/02-verify/run.json b/docs/build/02-verify/run.json index 1f965c6..282e303 100644 --- a/docs/build/02-verify/run.json +++ b/docs/build/02-verify/run.json @@ -4,26 +4,39 @@ "title": "Verify a purchase", "built": "Fixture store adapter + purchase persistence", "result": "Valid evidence is saved. Alice still has no access.", - "startedAt": "2026-09-07T12:06:43.488Z", - "recordedAt": "2026-09-07T12:06:46.405Z", - "packageVersion": "0.1.0", + "previous": "01-contract", + "startedAt": "2026-09-16T00:53:52.557Z", + "recordedAt": "2026-09-16T00:53:53.254Z", + "packageVersion": "0.3.0", "task": "# Verify a purchase\n\nAdd a fictional store adapter and verification. Persist accepted evidence without granting access. Invalid evidence must be a negative verdict; an upstream outage must be an error. Prove that binding is still unimplemented.\n\nUse the existing reviewed prototype where it satisfies the installed contract.\nRun the backend, inspect the result, correct problems, and rerun the affected check.\nKeep all work uncommitted.\n", "sourceHashes": { - "checkpoint.json": "80babe0222d635c4cf8accbdc94d85847d0bcf50e7568c61aac8772e6627d613", + "verify-erasure.mjs": "5d6864387941a103d1f92b441eb34c96a50026068ead13c8b669ba06f7b7be4f", + "checkpoint.json": "f296f8a43757874e98eb981bfbb4a6c126aa28bc10284b8dda7507134ce557ef", "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", - "provider.mjs": "3940a0eaa561d8662403a52130b48a6e347e7e37fe76a714f04ce17f628cf936", - "capture.mjs": "db84e47044c19bb806f74cdbd79492a804a67f28b2942446e7d85b08dbf9b94d", + "export-docs.mjs": "47cf25c7ba1942fb833ac6b496c1e06b4ae06a61f6328330c6a6a4337c164f84", + "erasure.mjs": "2dddb3ffd929c9d05333e88193f7cb3e5874ef5b29e68c68a1b25997d09788ea", + "client-bridge.mjs": "a6760f1a9059e650fa19ef96403b16f7860390fce390262276020bd2cc7d5078", + "provider.mjs": "92591dde34d4043e10524ffed5afa2aa8a9b5c0d1095bb89844629cbb869b65f", + "capture.mjs": "a6e95d6708fb9965a6b7c6d8bc1b6590f2c208c6a32a5a0029a05cfea702469b", "ai-task.md": "5f7bfaf63ca4fc04d88a2d60b1004e1bcab06a8077c0d367110e74e442c2f000", - "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", + "contract.mjs": "474dfee661cf00617872f6f50c3f4f5e88706e80d2fd6fcdda9304f489268c73", "dashboard.html": "a375bc19ff6e419a4dedd8680d0baf50d8a5336cfe2e0ba4036463329fd8e076", "verify.mjs": "8c33fba5e6dff85457c6154dbb5e0825ae9c299a193903fb40eda87be9a68211", "README.md": "ea83c0f24a18005c4e86aa3a951f4a7ac0f605537a7aa0e8fe2a179564a25771", + "verify-stores.mjs": "6a9ea6071aebddac158435b8577f4b31914d902191f6cc1476683ea6d9bac3dd", ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", - "package-lock.json": "7b7e6d588b495fd74020864325d8004ad797fb6b7fc563fc373f07c5c2501479", - "package.json": "191b82f635869339c5ae0ec9071115deba3813a73cc6060d99899b9290e38731", + "package-lock.json": "ef250412311d92295bed891735036a0d9bec1f8a24774316573f13699ab71cec", + "package.json": "a32429c9bb82d7f9cb1ea6ed4f022d46eea6364477572e63f7cc4a854b799ec2", + "consumer.mjs": "f9cdc5eff17f322dd2ae146546cfc5ed9b5f1a02495294b83bc17ec227aff18f", "BUILD.md": "a21280d90624e644da240d309b726bc8c7d07c1eddcafe7e3c2333d8451e6643", + "webhooks.mjs": "e6da4eb939244f1b05ad61307f717a2214bf830da9d27993beb969676cc936a6", "server.mjs": "3b4813c7907026488b29c163a8a2ce066402fe0bc2336e126c709e582af7a455", - "scenario.mjs": "7d0f12cadb632feed2164ba0ef793adfff1e0b3b70711761898fe232b8076873" + "scenario.mjs": "7d0f12cadb632feed2164ba0ef793adfff1e0b3b70711761898fe232b8076873", + "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", + "checkpoint-tools.mjs": "137fb2798a7e73e2db9a716d8cd3ea78fe3155ab0651ecae39f1e629634ec19c", + "AGENTS.md": "38764ea47552533ab5db55f7ade8c0025e2d7af9163715ce89f2d719f5475712", + "INTEGRATE.md": "ed3851d6432deedb85d2d0cc3edcca8fe52f58c79c99b68a9aa54616b010949b", + "checkpoint-tools.test.mjs": "1ea348330db8dab85694ac79cfec76e9d3af1729263a6a05cae61e10e41b2175" }, "checks": [ "Fixture request matches the installed schema", diff --git a/docs/build/02-verify/source.tar.gz b/docs/build/02-verify/source.tar.gz index 382953b..45e7306 100644 Binary files a/docs/build/02-verify/source.tar.gz and b/docs/build/02-verify/source.tar.gz differ diff --git a/docs/build/03-bind/attempt-1.txt b/docs/build/03-bind/attempt-1.txt index e992b41..095728e 100644 --- a/docs/build/03-bind/attempt-1.txt +++ b/docs/build/03-bind/attempt-1.txt @@ -1,5 +1,5 @@ { - "startedAt": "2026-09-07T12:06:56.316Z", + "startedAt": "2026-09-16T00:53:54.389Z", "command": "npm test", "passed": 15, "checks": [ diff --git a/docs/build/03-bind/changes.patch b/docs/build/03-bind/changes.patch index a2e76da..0141796 100644 --- a/docs/build/03-bind/changes.patch +++ b/docs/build/03-bind/changes.patch @@ -12,24 +12,26 @@ index 239ce5c..fe0a957 100644 Use the existing reviewed prototype where it satisfies the installed contract. Run the backend, inspect the result, correct problems, and rerun the affected check. diff --git a/checkpoint.json b/checkpoint.json -index 9b6e1f2..2381a61 100644 +index 5053480..2339583 100644 --- a/checkpoint.json +++ b/checkpoint.json -@@ -1,7 +1,7 @@ +@@ -1,8 +1,8 @@ { - "step": 2, - "id": "02-verify", - "title": "Verify a purchase", - "built": "Fixture store adapter + purchase persistence", -- "result": "Valid evidence is saved. Alice still has no access." +- "result": "Valid evidence is saved. Alice still has no access.", +- "previous": "01-contract" + "step": 3, + "id": "03-bind", + "title": "Connect it to a user", + "built": "Server authorization + atomic binding + entitlement reads", -+ "result": "Alice gets Premium. Another user cannot take the purchase." ++ "result": "Alice gets Premium. Another user cannot take the purchase.", ++ "previous": "02-verify" } diff --git a/provider.mjs b/provider.mjs -index c90643a..be4eb4b 100644 +index 08c4ada..7019c85 100644 --- a/provider.mjs +++ b/provider.mjs @@ -97,6 +97,30 @@ export function createProvider(path, now) { diff --git a/docs/build/03-bind/run.json b/docs/build/03-bind/run.json index 923cc20..0066266 100644 --- a/docs/build/03-bind/run.json +++ b/docs/build/03-bind/run.json @@ -4,26 +4,39 @@ "title": "Connect it to a user", "built": "Server authorization + atomic binding + entitlement reads", "result": "Alice gets Premium. Another user cannot take the purchase.", - "startedAt": "2026-09-07T12:06:56.316Z", - "recordedAt": "2026-09-07T12:06:57.641Z", - "packageVersion": "0.1.0", + "previous": "02-verify", + "startedAt": "2026-09-16T00:53:54.389Z", + "recordedAt": "2026-09-16T00:53:55.144Z", + "packageVersion": "0.3.0", "task": "# Connect it to a user\n\nAdd server-only ownership binding, subscription status, and entitlement reads. Verify the same owner can repeat a bind, another owner cannot take it, and verification credentials cannot bind. Show Alice gaining Premium.\n\nUse the existing reviewed prototype where it satisfies the installed contract.\nRun the backend, inspect the result, correct problems, and rerun the affected check.\nKeep all work uncommitted.\n", "sourceHashes": { - "checkpoint.json": "96726488289ce4ca4dc0a3132bdde4365ca8c3b50326a0757983e05f862d0bb5", + "verify-erasure.mjs": "5d6864387941a103d1f92b441eb34c96a50026068ead13c8b669ba06f7b7be4f", + "checkpoint.json": "f8b8fc67106c17ccc07a8fe9e91848c413e5490bb08418d8c5cc80f61befbe3d", "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", - "provider.mjs": "1dcf539a87cc01e19b67811deb7bbf11284c77fd2221786655088bececcb9aa4", - "capture.mjs": "db84e47044c19bb806f74cdbd79492a804a67f28b2942446e7d85b08dbf9b94d", + "export-docs.mjs": "47cf25c7ba1942fb833ac6b496c1e06b4ae06a61f6328330c6a6a4337c164f84", + "erasure.mjs": "2dddb3ffd929c9d05333e88193f7cb3e5874ef5b29e68c68a1b25997d09788ea", + "client-bridge.mjs": "a6760f1a9059e650fa19ef96403b16f7860390fce390262276020bd2cc7d5078", + "provider.mjs": "7c7726a75a0f5e5579871aaf1a1a94463a41aacc0974619d41fe12101a36ddad", + "capture.mjs": "a6e95d6708fb9965a6b7c6d8bc1b6590f2c208c6a32a5a0029a05cfea702469b", "ai-task.md": "c21879f0fe93a34e46fa795510e952a35f3eaf54a26cdfa145cc2a9aad4c3607", - "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", + "contract.mjs": "474dfee661cf00617872f6f50c3f4f5e88706e80d2fd6fcdda9304f489268c73", "dashboard.html": "a375bc19ff6e419a4dedd8680d0baf50d8a5336cfe2e0ba4036463329fd8e076", "verify.mjs": "2b880d5c56020b4947fb42328906d2f4a7c9f7d284b039261b374c7ebe331120", "README.md": "ea83c0f24a18005c4e86aa3a951f4a7ac0f605537a7aa0e8fe2a179564a25771", + "verify-stores.mjs": "6a9ea6071aebddac158435b8577f4b31914d902191f6cc1476683ea6d9bac3dd", ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", - "package-lock.json": "7b7e6d588b495fd74020864325d8004ad797fb6b7fc563fc373f07c5c2501479", - "package.json": "191b82f635869339c5ae0ec9071115deba3813a73cc6060d99899b9290e38731", + "package-lock.json": "ef250412311d92295bed891735036a0d9bec1f8a24774316573f13699ab71cec", + "package.json": "a32429c9bb82d7f9cb1ea6ed4f022d46eea6364477572e63f7cc4a854b799ec2", + "consumer.mjs": "f9cdc5eff17f322dd2ae146546cfc5ed9b5f1a02495294b83bc17ec227aff18f", "BUILD.md": "a21280d90624e644da240d309b726bc8c7d07c1eddcafe7e3c2333d8451e6643", + "webhooks.mjs": "e6da4eb939244f1b05ad61307f717a2214bf830da9d27993beb969676cc936a6", "server.mjs": "3b4813c7907026488b29c163a8a2ce066402fe0bc2336e126c709e582af7a455", - "scenario.mjs": "85cec8c670283950c9499cec823a01efe7fc0dede418a04c2bd75d30d726fcb6" + "scenario.mjs": "85cec8c670283950c9499cec823a01efe7fc0dede418a04c2bd75d30d726fcb6", + "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", + "checkpoint-tools.mjs": "137fb2798a7e73e2db9a716d8cd3ea78fe3155ab0651ecae39f1e629634ec19c", + "AGENTS.md": "38764ea47552533ab5db55f7ade8c0025e2d7af9163715ce89f2d719f5475712", + "INTEGRATE.md": "ed3851d6432deedb85d2d0cc3edcca8fe52f58c79c99b68a9aa54616b010949b", + "checkpoint-tools.test.mjs": "1ea348330db8dab85694ac79cfec76e9d3af1729263a6a05cae61e10e41b2175" }, "checks": [ "Fixture request matches the installed schema", diff --git a/docs/build/03-bind/source.tar.gz b/docs/build/03-bind/source.tar.gz index ab28a27..c18a879 100644 Binary files a/docs/build/03-bind/source.tar.gz and b/docs/build/03-bind/source.tar.gz differ diff --git a/docs/build/04-cancel/attempt-1.txt b/docs/build/04-cancel/attempt-1.txt index edd57f0..c4dcdcb 100644 --- a/docs/build/04-cancel/attempt-1.txt +++ b/docs/build/04-cancel/attempt-1.txt @@ -1,5 +1,5 @@ { - "startedAt": "2026-09-07T12:08:15.658Z", + "startedAt": "2026-09-16T00:53:56.240Z", "command": "npm run capture", "verifier": "verifyLab() (also used by npm test)", "passed": 18, diff --git a/docs/build/04-cancel/attempt-2.txt b/docs/build/04-cancel/attempt-2.txt deleted file mode 100644 index 6c071a0..0000000 --- a/docs/build/04-cancel/attempt-2.txt +++ /dev/null @@ -1,26 +0,0 @@ -{ - "startedAt": "2026-09-07T12:09:01.405Z", - "command": "npm run capture", - "verifier": "verifyLab() (also used by npm test)", - "passed": 18, - "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", - "Cancellation keeps paid access", - "Cancellation stops renewal", - "Cancellation queues one event", - "An unimplemented operation is explicitly refused", - "Untrusted browser origins are refused" - ] -} \ No newline at end of file diff --git a/docs/build/04-cancel/changes.patch b/docs/build/04-cancel/changes.patch index d820f26..d25674b 100644 --- a/docs/build/04-cancel/changes.patch +++ b/docs/build/04-cancel/changes.patch @@ -12,7 +12,7 @@ index fe0a957..251443c 100644 Use the existing reviewed prototype where it satisfies the installed contract. Run the backend, inspect the result, correct problems, and rerun the affected check. diff --git a/capture.mjs b/capture.mjs -index ed28b0c..7a4ffc3 100644 +index 5435689..dd54545 100644 --- a/capture.mjs +++ b/capture.mjs @@ -18,7 +18,7 @@ const startedAt = new Date().toISOString(); @@ -56,21 +56,23 @@ index ed28b0c..7a4ffc3 100644 await browser.close(); await lab.close(); diff --git a/checkpoint.json b/checkpoint.json -index 2381a61..8fdd5c2 100644 +index 2339583..79798b7 100644 --- a/checkpoint.json +++ b/checkpoint.json -@@ -1,7 +1,7 @@ +@@ -1,8 +1,8 @@ { - "step": 3, - "id": "03-bind", - "title": "Connect it to a user", - "built": "Server authorization + atomic binding + entitlement reads", -- "result": "Alice gets Premium. Another user cannot take the purchase." +- "result": "Alice gets Premium. Another user cannot take the purchase.", +- "previous": "02-verify" + "step": 4, + "id": "04-cancel", + "title": "Handle cancellation", + "built": "Lifecycle processing + transactional event outbox", -+ "result": "Renewal stops. Alice keeps the time she already paid for." ++ "result": "Renewal stops. Alice keeps the time she already paid for.", ++ "previous": "03-bind" } diff --git a/dashboard.html b/dashboard.html index 85b613b..b283719 100644 @@ -130,7 +132,7 @@ index 85b613b..b283719 100644 $("next").onclick = async () => { $("next").disabled = true; diff --git a/provider.mjs b/provider.mjs -index be4eb4b..1e43f78 100644 +index 7019c85..b8028f2 100644 --- a/provider.mjs +++ b/provider.mjs @@ -47,6 +47,12 @@ export function createProvider(path, now) { @@ -156,7 +158,7 @@ index be4eb4b..1e43f78 100644 + ); + const supported = new Set(["initialValidation", "subscriptions", "entitlements", "serverNotifications"]); + const capabilities = { -+ specVersion: HTTP_BINDING.protocolVersion, ++ commerceProtocolVersion: HTTP_BINDING.protocolVersion, + implementation: { name: "Commerce Protocol Example — fictional fixture store" }, + eventTypes, + stores: { diff --git a/docs/build/04-cancel/run.json b/docs/build/04-cancel/run.json index a1ab7d2..f94a6bb 100644 --- a/docs/build/04-cancel/run.json +++ b/docs/build/04-cancel/run.json @@ -4,26 +4,39 @@ "title": "Handle cancellation", "built": "Lifecycle processing + transactional event outbox", "result": "Renewal stops. Alice keeps the time she already paid for.", - "startedAt": "2026-09-07T12:09:01.405Z", - "recordedAt": "2026-09-07T12:09:04.075Z", - "packageVersion": "0.1.0", + "previous": "03-bind", + "startedAt": "2026-09-16T00:53:56.240Z", + "recordedAt": "2026-09-16T00:53:57.177Z", + "packageVersion": "0.3.0", "task": "# Handle cancellation\n\nAdd cancellation observations and a transactional event outbox. A cancellation stops renewal but preserves the paid period. Do not add delivery until its own milestone.\n\nUse the existing reviewed prototype where it satisfies the installed contract.\nRun the backend, inspect the result, correct problems, and rerun the affected check.\nKeep all work uncommitted.\n", "sourceHashes": { - "checkpoint.json": "0b04897a2716e3097685d1c9fd8be1d13533bac0de979b136d16a7af310d92f1", + "verify-erasure.mjs": "5d6864387941a103d1f92b441eb34c96a50026068ead13c8b669ba06f7b7be4f", + "checkpoint.json": "8ce32597901afcca1207e5d4b07bc1c5b4d6f96a4deff51e6e9781e9f2446f34", "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", - "provider.mjs": "105fc37166bdd4e5e4c53fcd1b4602563ab161863f14b704c4ed1a6e5c797a60", - "capture.mjs": "43309016734e2505ea83948656d4d36ac3f02418ad8e4ea4e7b8fd1d4fc344e4", + "export-docs.mjs": "47cf25c7ba1942fb833ac6b496c1e06b4ae06a61f6328330c6a6a4337c164f84", + "erasure.mjs": "2dddb3ffd929c9d05333e88193f7cb3e5874ef5b29e68c68a1b25997d09788ea", + "client-bridge.mjs": "a6760f1a9059e650fa19ef96403b16f7860390fce390262276020bd2cc7d5078", + "provider.mjs": "ac33e4532908f20dd5b13ae2124d0f6d6600e856749814303dc68b8645b4aa19", + "capture.mjs": "961f9c40bcea9ae2f36fa4216c933e5953a722a403c2f12fab65a8e00f89aecc", "ai-task.md": "b3246d94bb3920373d01874b39032ff85950ef772003437fd2d551a242a2ce3e", - "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", + "contract.mjs": "474dfee661cf00617872f6f50c3f4f5e88706e80d2fd6fcdda9304f489268c73", "dashboard.html": "7af8dacc8023922eb7705d8160c583b385eb4c293fa1b0b38327e92aa816cf9c", "verify.mjs": "2b880d5c56020b4947fb42328906d2f4a7c9f7d284b039261b374c7ebe331120", "README.md": "ea83c0f24a18005c4e86aa3a951f4a7ac0f605537a7aa0e8fe2a179564a25771", + "verify-stores.mjs": "6a9ea6071aebddac158435b8577f4b31914d902191f6cc1476683ea6d9bac3dd", ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", - "package-lock.json": "7b7e6d588b495fd74020864325d8004ad797fb6b7fc563fc373f07c5c2501479", - "package.json": "191b82f635869339c5ae0ec9071115deba3813a73cc6060d99899b9290e38731", + "package-lock.json": "ef250412311d92295bed891735036a0d9bec1f8a24774316573f13699ab71cec", + "package.json": "a32429c9bb82d7f9cb1ea6ed4f022d46eea6364477572e63f7cc4a854b799ec2", + "consumer.mjs": "f9cdc5eff17f322dd2ae146546cfc5ed9b5f1a02495294b83bc17ec227aff18f", "BUILD.md": "a21280d90624e644da240d309b726bc8c7d07c1eddcafe7e3c2333d8451e6643", + "webhooks.mjs": "e6da4eb939244f1b05ad61307f717a2214bf830da9d27993beb969676cc936a6", "server.mjs": "3b4813c7907026488b29c163a8a2ce066402fe0bc2336e126c709e582af7a455", - "scenario.mjs": "36cd5b8b64065aecb8510259cf362fbebf38b3e5dab46dcac45d7cc633c858c2" + "scenario.mjs": "36cd5b8b64065aecb8510259cf362fbebf38b3e5dab46dcac45d7cc633c858c2", + "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", + "checkpoint-tools.mjs": "137fb2798a7e73e2db9a716d8cd3ea78fe3155ab0651ecae39f1e629634ec19c", + "AGENTS.md": "38764ea47552533ab5db55f7ade8c0025e2d7af9163715ce89f2d719f5475712", + "INTEGRATE.md": "ed3851d6432deedb85d2d0cc3edcca8fe52f58c79c99b68a9aa54616b010949b", + "checkpoint-tools.test.mjs": "1ea348330db8dab85694ac79cfec76e9d3af1729263a6a05cae61e10e41b2175" }, "checks": [ "Fixture request matches the installed schema", @@ -65,7 +78,7 @@ "operation": "providerCapabilities", "httpStatus": 200, "body": { - "specVersion": "1.0", + "commerceProtocolVersion": "1.0", "implementation": { "name": "Commerce Protocol Example — fictional fixture store" }, @@ -127,7 +140,7 @@ "operation": "providerCapabilities", "httpStatus": 200, "body": { - "specVersion": "1.0", + "commerceProtocolVersion": "1.0", "implementation": { "name": "Commerce Protocol Example — fictional fixture store" }, @@ -391,8 +404,8 @@ }, "deliveries": [ { - "eventId": "0a0c5a72-1ff1-4ecc-9968-e6f2dad5848f", - "deliveryId": "a5848b4c-bc76-4953-b8af-82d7d937cb48", + "eventId": "7724af02-869c-44c4-8d6e-bab6b82052f4", + "deliveryId": "3d36cfcf-0faf-42db-9f03-15c792cbb0f4", "attempts": 0, "status": "pending", "eventType": "subscription.canceled" diff --git a/docs/build/04-cancel/source.tar.gz b/docs/build/04-cancel/source.tar.gz index 9e171a4..96ce763 100644 Binary files a/docs/build/04-cancel/source.tar.gz and b/docs/build/04-cancel/source.tar.gz differ diff --git a/docs/build/05-deliver/attempt-1.txt b/docs/build/05-deliver/attempt-1.txt index 466f0ba..ba0f8d8 100644 --- a/docs/build/05-deliver/attempt-1.txt +++ b/docs/build/05-deliver/attempt-1.txt @@ -1,5 +1,5 @@ { - "startedAt": "2026-09-07T12:09:13.991Z", + "startedAt": "2026-09-16T00:54:00.397Z", "command": "npm run capture", "verifier": "verifyLab() (also used by npm test)", "passed": 22, diff --git a/docs/build/05-deliver/changes.patch b/docs/build/05-deliver/changes.patch index 422de61..e784502 100644 --- a/docs/build/05-deliver/changes.patch +++ b/docs/build/05-deliver/changes.patch @@ -12,21 +12,23 @@ index 251443c..50efd02 100644 Use the existing reviewed prototype where it satisfies the installed contract. Run the backend, inspect the result, correct problems, and rerun the affected check. diff --git a/checkpoint.json b/checkpoint.json -index 8fdd5c2..0f3893c 100644 +index 79798b7..010fb78 100644 --- a/checkpoint.json +++ b/checkpoint.json -@@ -1,7 +1,7 @@ +@@ -1,8 +1,8 @@ { - "step": 4, - "id": "04-cancel", - "title": "Handle cancellation", - "built": "Lifecycle processing + transactional event outbox", -- "result": "Renewal stops. Alice keeps the time she already paid for." +- "result": "Renewal stops. Alice keeps the time she already paid for.", +- "previous": "03-bind" + "step": 5, + "id": "05-deliver", + "title": "Deliver, retry, deduplicate", + "built": "HMAC signatures + retry worker + durable receiver inbox", -+ "result": "A 503 retries successfully. Redelivery creates no second inbox row." ++ "result": "A 503 retries successfully. Redelivery creates no second inbox row.", ++ "previous": "04-cancel" } diff --git a/scenario.mjs b/scenario.mjs index 704b80e..366fb01 100644 @@ -163,128 +165,148 @@ index fbac7e0..ad3e921 100644 }; } diff --git a/webhooks.mjs b/webhooks.mjs -new file mode 100644 -index 0000000..796dc79 ---- /dev/null +index df8523d..f3f38d3 100644 +--- a/webhooks.mjs +++ b/webhooks.mjs -@@ -0,0 +1,120 @@ -+import { Database } from "bun:sqlite"; -+import { createHmac, timingSafeEqual } from "node:crypto"; -+import { WEBHOOK } from "openiap-commerce-protocol"; -+import { validate } from "./contract.mjs"; -+ -+export function sign(secret, timestamp, body) { -+ return ( -+ WEBHOOK.signaturePrefix + +@@ -2,15 +2,11 @@ import { Database } from "bun:sqlite"; + import { createHmac, timingSafeEqual } from "node:crypto"; + import { WEBHOOK } from "@hyodotdev/openiap-commerce-protocol"; + import { validate } from "./contract.mjs"; +-import { createErasureLedger } from "./erasure.mjs"; + + export function sign(secret, timestamp, body) { + return ( + WEBHOOK.signaturePrefix + +- createHmac("sha256", secret) +- .update(`${timestamp}.`) +- .update(body) +- .digest("hex") + createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex") -+ ); -+} -+ -+export function authentic(secrets, timestamp, body, signatures, nowSeconds) { -+ if (!/^\d+$/.test(timestamp ?? "")) return false; -+ if ( -+ !Number.isSafeInteger(Number(timestamp)) || -+ Math.abs(nowSeconds - Number(timestamp)) > WEBHOOK.toleranceSeconds -+ ) -+ return false; -+ return (signatures ?? "").split(",").some((raw) => { -+ const candidate = raw.trim(); -+ if (!/^v1=[a-f0-9]{64}$/.test(candidate)) return false; -+ return secrets.some((secret) => -+ timingSafeEqual( -+ Buffer.from(candidate), -+ Buffer.from(sign(secret, timestamp, body)), -+ ), -+ ); -+ }); -+} -+ -+export function createReceiver(path, secret, now) { -+ const db = new Database(path, { create: true }); -+ db.exec( -+ "CREATE TABLE IF NOT EXISTS inbox (event_id TEXT PRIMARY KEY, body TEXT NOT NULL)", -+ ); -+ -+ async function fetch(request) { + ); + } + +@@ -34,63 +30,26 @@ 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()); +- const authenticated = emitters.filter((emitter) => +- authentic( +- [emitter.secret], + const body = await request.text(); + if ( + !authentic( + [secret], -+ request.headers.get(WEBHOOK.timestampHeader), + request.headers.get(WEBHOOK.timestampHeader), +- bytes, + body, -+ request.headers.get(WEBHOOK.signatureHeader), -+ Math.floor(now() / 1000), + request.headers.get(WEBHOOK.signatureHeader), + Math.floor(now() / 1000), +- ), +- ); +- if (!authenticated.length) { + ) + ) { -+ return new Response("Invalid signature", { status: 401 }); -+ } + return new Response("Invalid signature", { status: 401 }); + } +- let body, event; + let event; -+ try { -+ event = JSON.parse(body); -+ } catch { -+ return new Response("Invalid JSON", { status: 400 }); -+ } -+ if (!validate("#/$defs/CommerceEvent", event)) -+ return new Response("Invalid event", { status: 400 }); -+ if (request.headers.get(WEBHOOK.eventIdHeader) !== event.eventId) -+ return new Response("Event ID mismatch", { status: 400 }); -+ // Inbox insertion is the durable effect; downstream jobs can consume it later. -+ const result = db -+ .query("INSERT OR IGNORE INTO inbox VALUES (?, ?)") + try { +- body = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + event = JSON.parse(body); + } catch { + return new Response("Invalid JSON", { status: 400 }); +@@ -99,38 +58,15 @@ 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( +- JSON.stringify([emitter.name, event.projectId, event.eventId]), +- body, +- ); + .run(event.eventId, body); -+ return Response.json({ accepted: true, duplicate: result.changes === 0 }); -+ } -+ -+ return { -+ fetch, -+ count: () => db.query("SELECT count(*) AS count FROM inbox").get().count, -+ close: () => db.close(), -+ }; -+} -+ -+// The caller injects a loopback test transport. This is not a public HTTPS worker. -+export async function deliver(provider, secret, now, post) { -+ const results = []; -+ const rows = provider.db -+ .query( -+ "SELECT * FROM outbox WHERE status = 'pending' AND next_at <= ? ORDER BY rowid", -+ ) -+ .all(now()); + 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(), + }; +@@ -144,11 +80,7 @@ export async function deliver(provider, secret, now, post) { + "SELECT * FROM outbox WHERE status = 'pending' AND next_at <= ? ORDER BY rowid", + ) + .all(now()); +- 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; + for (const row of rows) { -+ const timestamp = Math.floor(now() / 1000).toString(); -+ const headers = { -+ "content-type": WEBHOOK.contentType, -+ [WEBHOOK.timestampHeader]: timestamp, -+ [WEBHOOK.signatureHeader]: sign(secret, timestamp, row.body), -+ [WEBHOOK.eventIdHeader]: row.event_id, -+ [WEBHOOK.deliveryIdHeader]: row.delivery_id, -+ }; -+ let status; -+ try { -+ status = (await post({ method: "POST", headers, body: row.body })).status; -+ } catch { -+ status = 503; -+ } -+ const attempts = row.attempts + 1; -+ const retryable = status === 408 || status === 429 || status >= 500; -+ const next = -+ status >= 200 && status < 300 -+ ? "delivered" -+ : retryable && attempts < 3 -+ ? "pending" -+ : "dead-letter"; -+ provider.db -+ .query( -+ "UPDATE outbox SET attempts = ?, status = ?, next_at = ? WHERE event_id = ?", -+ ) -+ .run(attempts, next, now() + 30_000 * 2 ** (attempts - 1), row.event_id); -+ results.push({ -+ eventId: row.event_id, -+ deliveryId: row.delivery_id, -+ httpStatus: status, -+ attempt: attempts, -+ status: next, -+ }); -+ } -+ return results; -+} + const timestamp = Math.floor(now() / 1000).toString(); + const headers = { + "content-type": WEBHOOK.contentType, diff --git a/docs/build/05-deliver/mobile.png b/docs/build/05-deliver/mobile.png index b9a27dc..e1c267a 100644 Binary files a/docs/build/05-deliver/mobile.png and b/docs/build/05-deliver/mobile.png differ diff --git a/docs/build/05-deliver/run.json b/docs/build/05-deliver/run.json index 72d5ccb..f0271ec 100644 --- a/docs/build/05-deliver/run.json +++ b/docs/build/05-deliver/run.json @@ -4,27 +4,39 @@ "title": "Deliver, retry, deduplicate", "built": "HMAC signatures + retry worker + durable receiver inbox", "result": "A 503 retries successfully. Redelivery creates no second inbox row.", - "startedAt": "2026-09-07T12:09:13.991Z", - "recordedAt": "2026-09-07T12:09:16.165Z", - "packageVersion": "0.1.0", + "previous": "04-cancel", + "startedAt": "2026-09-16T00:54:00.397Z", + "recordedAt": "2026-09-16T00:54:01.413Z", + "packageVersion": "0.3.0", "task": "# Deliver, retry, deduplicate\n\nAdd an HMAC receiver, durable inbox, and bounded retry worker. Exercise 503, reopen the database, retry successfully, then simulate a lost acknowledgement. Redelivery must have one inbox effect.\n\nUse the existing reviewed prototype where it satisfies the installed contract.\nRun the backend, inspect the result, correct problems, and rerun the affected check.\nKeep all work uncommitted.\n", "sourceHashes": { - "checkpoint.json": "b41bff5a6b9e4bcda79452deb626df17c0a62c7a11646bc2b625f7d32ac93c28", + "verify-erasure.mjs": "5d6864387941a103d1f92b441eb34c96a50026068ead13c8b669ba06f7b7be4f", + "checkpoint.json": "f054450c56ddcec47ea9ad0e241a1cd935455186b7639cd6e0ead4f49de972e3", "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", - "provider.mjs": "105fc37166bdd4e5e4c53fcd1b4602563ab161863f14b704c4ed1a6e5c797a60", - "capture.mjs": "43309016734e2505ea83948656d4d36ac3f02418ad8e4ea4e7b8fd1d4fc344e4", + "export-docs.mjs": "47cf25c7ba1942fb833ac6b496c1e06b4ae06a61f6328330c6a6a4337c164f84", + "erasure.mjs": "2dddb3ffd929c9d05333e88193f7cb3e5874ef5b29e68c68a1b25997d09788ea", + "client-bridge.mjs": "a6760f1a9059e650fa19ef96403b16f7860390fce390262276020bd2cc7d5078", + "provider.mjs": "ac33e4532908f20dd5b13ae2124d0f6d6600e856749814303dc68b8645b4aa19", + "capture.mjs": "961f9c40bcea9ae2f36fa4216c933e5953a722a403c2f12fab65a8e00f89aecc", "ai-task.md": "d7a049dd20511867eedb48f8eb8d3b283ac6fbc949e9930db6a81de3eeea9bd7", - "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", + "contract.mjs": "474dfee661cf00617872f6f50c3f4f5e88706e80d2fd6fcdda9304f489268c73", "dashboard.html": "7af8dacc8023922eb7705d8160c583b385eb4c293fa1b0b38327e92aa816cf9c", "verify.mjs": "2b880d5c56020b4947fb42328906d2f4a7c9f7d284b039261b374c7ebe331120", "README.md": "ea83c0f24a18005c4e86aa3a951f4a7ac0f605537a7aa0e8fe2a179564a25771", + "verify-stores.mjs": "6a9ea6071aebddac158435b8577f4b31914d902191f6cc1476683ea6d9bac3dd", ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", - "package-lock.json": "7b7e6d588b495fd74020864325d8004ad797fb6b7fc563fc373f07c5c2501479", - "package.json": "191b82f635869339c5ae0ec9071115deba3813a73cc6060d99899b9290e38731", + "package-lock.json": "ef250412311d92295bed891735036a0d9bec1f8a24774316573f13699ab71cec", + "package.json": "a32429c9bb82d7f9cb1ea6ed4f022d46eea6364477572e63f7cc4a854b799ec2", + "consumer.mjs": "f9cdc5eff17f322dd2ae146546cfc5ed9b5f1a02495294b83bc17ec227aff18f", "BUILD.md": "a21280d90624e644da240d309b726bc8c7d07c1eddcafe7e3c2333d8451e6643", - "webhooks.mjs": "6124cf3bdb4404c2f5a712ae8e9fc8e3382a169f8aa79d0e5476a7d5773b9800", + "webhooks.mjs": "9f1ada27b9e8e4e117b6f44b862a7f007b859249104cea8f9c40840559a2dafb", "server.mjs": "44fe1930e987c0aca101d0360cd3a6cb552d344c443ea62aead9cd0e607ea143", - "scenario.mjs": "ddb3986a34adc0f0daaa0074fa569f385dddd10db250ca5870d5113b2b2df216" + "scenario.mjs": "ddb3986a34adc0f0daaa0074fa569f385dddd10db250ca5870d5113b2b2df216", + "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", + "checkpoint-tools.mjs": "137fb2798a7e73e2db9a716d8cd3ea78fe3155ab0651ecae39f1e629634ec19c", + "AGENTS.md": "38764ea47552533ab5db55f7ade8c0025e2d7af9163715ce89f2d719f5475712", + "INTEGRATE.md": "ed3851d6432deedb85d2d0cc3edcca8fe52f58c79c99b68a9aa54616b010949b", + "checkpoint-tools.test.mjs": "1ea348330db8dab85694ac79cfec76e9d3af1729263a6a05cae61e10e41b2175" }, "checks": [ "Fixture request matches the installed schema", @@ -70,7 +82,7 @@ "operation": "providerCapabilities", "httpStatus": 200, "body": { - "specVersion": "1.0", + "commerceProtocolVersion": "1.0", "implementation": { "name": "Commerce Protocol Example — fictional fixture store" }, @@ -132,7 +144,7 @@ "operation": "providerCapabilities", "httpStatus": 200, "body": { - "specVersion": "1.0", + "commerceProtocolVersion": "1.0", "implementation": { "name": "Commerce Protocol Example — fictional fixture store" }, @@ -396,8 +408,8 @@ }, "deliveries": [ { - "eventId": "2b85fc8f-5d6d-4d20-977e-9f76560a4f53", - "deliveryId": "bbba62cf-605e-438e-8dc2-041209da1185", + "eventId": "4ee7cc1c-5469-411b-817c-954fac3203e3", + "deliveryId": "7757ae34-dd0d-49d8-a320-75dee7dae657", "attempts": 0, "status": "pending", "eventType": "subscription.canceled" @@ -460,8 +472,8 @@ }, "deliveries": [ { - "eventId": "2b85fc8f-5d6d-4d20-977e-9f76560a4f53", - "deliveryId": "bbba62cf-605e-438e-8dc2-041209da1185", + "eventId": "4ee7cc1c-5469-411b-817c-954fac3203e3", + "deliveryId": "7757ae34-dd0d-49d8-a320-75dee7dae657", "attempts": 3, "status": "delivered", "eventType": "subscription.canceled" @@ -473,8 +485,8 @@ "operation": "webhook: receiver unavailable", "body": [ { - "eventId": "2b85fc8f-5d6d-4d20-977e-9f76560a4f53", - "deliveryId": "bbba62cf-605e-438e-8dc2-041209da1185", + "eventId": "4ee7cc1c-5469-411b-817c-954fac3203e3", + "deliveryId": "7757ae34-dd0d-49d8-a320-75dee7dae657", "httpStatus": 503, "attempt": 1, "status": "pending" @@ -485,8 +497,8 @@ "operation": "webhook: retry after restart", "body": [ { - "eventId": "2b85fc8f-5d6d-4d20-977e-9f76560a4f53", - "deliveryId": "bbba62cf-605e-438e-8dc2-041209da1185", + "eventId": "4ee7cc1c-5469-411b-817c-954fac3203e3", + "deliveryId": "7757ae34-dd0d-49d8-a320-75dee7dae657", "httpStatus": 200, "attempt": 2, "status": "delivered" @@ -497,8 +509,8 @@ "operation": "webhook: lost-ack redelivery", "body": [ { - "eventId": "2b85fc8f-5d6d-4d20-977e-9f76560a4f53", - "deliveryId": "bbba62cf-605e-438e-8dc2-041209da1185", + "eventId": "4ee7cc1c-5469-411b-817c-954fac3203e3", + "deliveryId": "7757ae34-dd0d-49d8-a320-75dee7dae657", "httpStatus": 200, "attempt": 3, "status": "delivered" diff --git a/docs/build/05-deliver/screen.png b/docs/build/05-deliver/screen.png index 0296c38..47f3673 100644 Binary files a/docs/build/05-deliver/screen.png and b/docs/build/05-deliver/screen.png differ diff --git a/docs/build/05-deliver/source.tar.gz b/docs/build/05-deliver/source.tar.gz index 085f1c6..e7e05bd 100644 Binary files a/docs/build/05-deliver/source.tar.gz and b/docs/build/05-deliver/source.tar.gz differ diff --git a/docs/build/06-recover-reviewed-2/attempt-1.txt b/docs/build/06-recover-reviewed-2/attempt-1.txt deleted file mode 100644 index b4765db..0000000 --- a/docs/build/06-recover-reviewed-2/attempt-1.txt +++ /dev/null @@ -1,20 +0,0 @@ -{ - "startedAt": "2026-09-07T13:46:59.784Z", - "commands": [ - { - "command": "npm ci --ignore-scripts --no-audit --no-fund", - "exitCode": 0, - "output": "\nadded 10 packages in 483ms\n" - }, - { - "command": "npm test", - "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs\n\nCommerce Lab: 118 checks passed. No store or production service contacted.\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) patches preserve path-like source text across additions, changes, and deletions [54.29ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.40ms]\n\n 2 pass\n 0 fail\nRan 2 tests across 1 file. [75.00ms]\n" - } - ] -} diff --git a/docs/build/06-recover-reviewed-2/changes.patch b/docs/build/06-recover-reviewed-2/changes.patch deleted file mode 100644 index e0d2964..0000000 --- a/docs/build/06-recover-reviewed-2/changes.patch +++ /dev/null @@ -1,1501 +0,0 @@ -diff --git a/BUILD.md b/BUILD.md -index a869c9b..7362fcc 100644 ---- a/BUILD.md -+++ b/BUILD.md -@@ -17,12 +17,16 @@ same package name. Read these files from the installed package directory - (normally `node_modules/openiap-commerce-protocol/`): - - - `SPEC.md`: normative behavior, authorization, lifecycle, and delivery rules. --- `DESIGN.md`: architecture and reasoning. - - `generated/openapi/commerce-protocol.openapi.json`: REST request/response API. - - `generated/bindings/http-binding.json`: operations, roles, and schema pointers. - - `generated/schemas/commerce-protocol.bundle.schema.json`: offline validation. - - `conformance/` and `vectors/`: portable checks and signature fixtures. - -+For architecture, use the [implementation guide](https://openiap.dev/commerce-protocol/implementation) -+and [whitepaper](https://openiap.dev/commerce-protocol/whitepaper). Package 0.1.0 -+does not include `DESIGN.md`; read that optional file only when it exists in -+your installed version. -+ - The package supplies the contract and test artifacts, not a running backend. - Implement the backend in my project. Do not require an OpenIAP or IAPKit checkout, - and do not invent request fields, response shapes, role rules, or enum values. -@@ -40,7 +44,7 @@ Build these milestones in order: - 1. **Contract:** serve capabilities and validate requests and responses against - the generated artifacts. Start with an empty persistent database. Advertise - only demonstrated support; do not claim partially implemented profiles. -- Protocol 0.1.0 requires a nonempty event list. Until an event emitter exists, -+ Package 0.1.0 (protocol 1.0) requires a nonempty event list. Until an event emitter exists, - treat this as unfinished scaffolding, not a provider ready for integration. - Core discovery cannot use `UNSUPPORTED_PROFILE` as a valid fallback. - 2. **Verification:** accept known fixture evidence, reject invalid evidence, and -diff --git a/README.md b/README.md -index b248bf5..c4cfc8b 100644 ---- a/README.md -+++ b/README.md -@@ -18,6 +18,35 @@ This example uses the Bun runtime for HTTP and SQLite. Its scripts work through - any package manager with Bun installed. The protocol can be implemented in your - own language and stack. Open http://127.0.0.1:5181 and run each available step. - -+## Receive events in an existing backend -+ -+The ready receiver verifies signatures and saves each event once in SQLite. -+Run the complete local integration check: -+ -+```sh -+npm run demo:consumer -+``` -+ -+It sends purchase, renewal, cancellation, expiry, refund, and entitlement -+samples over HTTP, retries each delivery, rejects tampering, and reopens the -+inbox. No store adapter or provider backend is needed. The inbox stores the -+signed event unchanged for your existing processing pipeline. -+ -+To run the receiver continuously, set `COMMERCE_WEBHOOK_SECRET` to your -+provider's signing secret and run `npm run consumer`. It listens at -+`http://127.0.0.1:5182/webhooks/commerce`, with storage in `consumer.sqlite` -+(or `COMMERCE_INBOX_PATH`). It is bound to loopback: an HTTPS reverse proxy -+must forward to that local address, preserving the exact body bytes. Configure -+one emitter/project and signing key per receiver database. Keep the inbox file -+on persistent storage. `webhooks.mjs` exports the same Fetch-compatible receiver -+handler for embedding in an existing server. -+ -+The local check proves ingestion, not store authenticity or financial analytics. -+Transaction and price fields are optional; missing values are unknown. A -+lifecycle event is not necessarily a new charge. Keep your own revenue and -+accounting rules; the receiver preserves the provider's fields without inventing -+a zero price or counting every lifecycle event as revenue. -+ - ## Build with AI - - Give [BUILD.md](BUILD.md) to your AI. Ask it to add one milestone, run it, inspect -@@ -29,8 +58,8 @@ an independently runnable checkpoint. Later functionality is absent from the - earlier source, rather than hidden behind a runtime step switch. The dashboard - replays the operations implemented at that checkpoint. - --This implementation extracts and extends the previously tested OpenIAP Commerce --Lab prototype. It is an incremental implementation record, not a claim that an -+This implementation extends an earlier internal prototype, called Commerce Lab, -+which this standalone repository replaces. It is an incremental implementation record, not a claim that an - AI produced the backend in one prompt without reference code. - - ## Scope -@@ -41,12 +70,15 @@ clock are fixtures. No real purchases or credentials are needed. Recovery - reopens both databases inside the same HTTP process; it does not test an OS - crash, process startup, or recovery of externally stored secrets. - --This is an educational part of the IAPKit architecture, not a production --provider. Real store validation, login, erasure, tenant isolation, GraphQL, -+This is an educational Commerce Protocol example. Real store validation, login, erasure, tenant isolation, GraphQL, - public HTTPS delivery, and full profile conformance remain separate work. - - ## Record another checkpoint - -+Recording needs Node.js with npm, Git, tar, and Google Chrome in addition to -+Bun. The backend's test/start scripts use Bun only. `npm run test:tooling` -+checks capture tooling separately. -+ - Update `ai-task.md` with the task and review notes. Run `npm test`, inspect the - dashboard, then `npm run capture` with Google Chrome installed. Capture runs the - checks again and preserves failed attempts. Capture installs and tests the archived source in a temporary project, then -diff --git a/ai-task.md b/ai-task.md -index 1d56736..0bea667 100644 ---- a/ai-task.md -+++ b/ai-task.md -@@ -13,3 +13,10 @@ 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. -diff --git a/capture.mjs b/capture.mjs -index 29eefe8..75621cc 100644 ---- a/capture.mjs -+++ b/capture.mjs -@@ -1,78 +1,167 @@ --import assert from 'node:assert/strict'; --import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, readdirSync, cpSync, rmSync } from 'node:fs'; --import { tmpdir } from 'node:os'; --import { join } from 'node:path'; --import { pathToFileURL } from 'node:url'; --import { chromium } from '@playwright/test'; --import { SOURCE_FILES, createPatch, extract, hashes, run, sanitize, sha256 } from './checkpoint-tools.mjs'; -+import assert from "node:assert/strict"; -+import { -+ existsSync, -+ mkdirSync, -+ mkdtempSync, -+ readFileSync, -+ writeFileSync, -+ cpSync, -+ rmSync, -+} from "node:fs"; -+import { tmpdir } from "node:os"; -+import { join } from "node:path"; -+import { pathToFileURL } from "node:url"; -+import { chromium } from "@playwright/test"; -+import { -+ SOURCE_FILES, -+ createPatch, -+ extract, -+ hashes, -+ run, -+ sanitize, -+ sha256, -+ nextAttempt, -+} from "./checkpoint-tools.mjs"; - - const root = import.meta.dir; --const checkpoint = JSON.parse(readFileSync(join(root, 'checkpoint.json'), 'utf8')); --assert(/^\d\d-[\w-]+$/.test(checkpoint.id), 'Use a simple checkpoint directory name'); --const output = join(root, 'docs/build', checkpoint.id); -+const checkpoint = JSON.parse( -+ readFileSync(join(root, "checkpoint.json"), "utf8"), -+); -+assert( -+ /^\d\d-[\w-]+$/.test(checkpoint.id), -+ "Use a simple checkpoint directory name", -+); -+const output = join(root, "docs/build", checkpoint.id); - mkdirSync(output, { recursive: true }); --assert(!existsSync(join(output, 'run.json')), 'Checkpoint already published; choose a new checkpoint id.'); --const attempt = readdirSync(output).filter(name => name.startsWith('attempt-')).length + 1; --const temp = mkdtempSync(join(tmpdir(), 'commerce-capture-')); --const current = join(temp, 'after'), previous = join(temp, 'before'); -+assert( -+ !existsSync(join(output, "run.json")), -+ "Checkpoint already published; choose a new checkpoint id.", -+); -+const attempt = nextAttempt(output); -+const temp = mkdtempSync(join(tmpdir(), "commerce-capture-")); -+const current = join(temp, "after"), -+ previous = join(temp, "before"); - const startedAt = new Date().toISOString(); - let lab, browser; - try { -- mkdirSync(current); mkdirSync(previous); -- for (const name of SOURCE_FILES) cpSync(join(root, name), join(current, name)); -+ mkdirSync(current); -+ mkdirSync(previous); -+ for (const name of SOURCE_FILES) -+ cpSync(join(root, name), join(current, name)); - const sourceHashes = hashes(current); - if (checkpoint.previous) { - assert(/^\d\d-[\w-]+$/.test(checkpoint.previous)); -- const prior = join(root, 'docs/build', checkpoint.previous); -- extract(join(prior, 'source.tar.gz'), previous); -- assert.deepEqual(hashes(previous), JSON.parse(readFileSync(join(prior, 'run.json'), 'utf8')).sourceHashes); -+ const prior = join(root, "docs/build", checkpoint.previous); -+ extract(join(prior, "source.tar.gz"), previous); -+ assert.deepEqual( -+ hashes(previous), -+ JSON.parse(readFileSync(join(prior, "run.json"), "utf8")).sourceHashes, -+ ); - } -- writeFileSync(join(output, 'changes.patch'), createPatch(previous, current)); -- run('tar', ['-czf', join(output, 'source.tar.gz'), '-C', current, ...SOURCE_FILES]); -- const commands = [run('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], current), run('npm', ['test'], current)]; -- writeFileSync(join(output, `attempt-${attempt}.txt`), JSON.stringify({ startedAt, commands }, null, 2) + '\n'); -- const { startLab } = await import(pathToFileURL(join(current, 'server.mjs')).href); -- const { verifyLab } = await import(pathToFileURL(join(current, 'verify.mjs')).href); -- const { STAGES } = await import(pathToFileURL(join(current, 'scenario.mjs')).href); -+ writeFileSync(join(output, "changes.patch"), createPatch(previous, current)); -+ run("tar", [ -+ "-czf", -+ join(output, "source.tar.gz"), -+ "-C", -+ current, -+ ...SOURCE_FILES, -+ ]); -+ const commands = [ -+ run("npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], current), -+ run("npm", ["test"], current), -+ run("npm", ["run", "test:tooling"], current), -+ ]; -+ writeFileSync( -+ join(output, `attempt-${attempt}.txt`), -+ JSON.stringify({ startedAt, commands }, null, 2) + "\n", -+ ); -+ const { startLab } = await import( -+ pathToFileURL(join(current, "server.mjs")).href -+ ); -+ const { verifyLab } = await import( -+ pathToFileURL(join(current, "verify.mjs")).href -+ ); -+ const { STAGES } = await import( -+ pathToFileURL(join(current, "scenario.mjs")).href -+ ); - const checks = await verifyLab(); - lab = startLab(); -- browser = await chromium.launch({ channel: 'chrome' }); -- const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } }); -+ browser = await chromium.launch({ channel: "chrome" }); -+ const page = await browser.newPage({ -+ viewport: { width: 1280, height: 1000 }, -+ }); - const errors = []; -- page.on('pageerror', error => errors.push(error.message)); -- await page.goto(lab.runtime.baseUrl, { waitUntil: 'domcontentloaded' }); -+ page.on("pageerror", (error) => errors.push(error.message)); -+ await page.goto(lab.runtime.baseUrl, { waitUntil: "domcontentloaded" }); - for (let step = 1; step <= STAGES.length; step++) { -- await page.getByRole('button', { name: `Run step ${step} →`, exact: true }).click(); -- await page.locator('#progress').filter({ hasText: `Milestone ${step} / ${STAGES.length}` }).waitFor(); -+ await page -+ .getByRole("button", { name: `Run step ${step} →`, exact: true }) -+ .click(); -+ await page -+ .locator("#progress") -+ .filter({ hasText: `Milestone ${step} / ${STAGES.length}` }) -+ .waitFor(); - } -- for (const [filename, viewport] of [['screen.png', { width: 1280, height: 1000 }], ['mobile.png', { width: 390, height: 844 }]]) { -+ for (const [filename, viewport] of [ -+ ["screen.png", { width: 1280, height: 1000 }], -+ ["mobile.png", { width: 390, height: 844 }], -+ ]) { - await page.setViewportSize(viewport); -- assert.equal(await page.locator('#error').textContent(), ''); -- assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); -- const responses = page.locator('#responses details'); -+ assert.equal(await page.locator("#error").textContent(), ""); -+ assert.equal( -+ await page.evaluate( -+ () => document.documentElement.scrollWidth > innerWidth, -+ ), -+ false, -+ ); -+ const responses = page.locator("#responses details"); - if (await responses.count()) { -- const wasOpen = await responses.last().getAttribute('open') !== null; -- if (wasOpen) await responses.last().locator('summary').click(); -- assert.equal(await responses.last().getAttribute('open'), null); -- await responses.last().locator('summary').click(); -- assert(await responses.last().locator('pre').isVisible()); -- if (!wasOpen) await responses.last().locator('summary').click(); -+ const wasOpen = (await responses.last().getAttribute("open")) !== null; -+ if (wasOpen) await responses.last().locator("summary").click(); -+ assert.equal(await responses.last().getAttribute("open"), null); -+ await responses.last().locator("summary").click(); -+ assert(await responses.last().locator("pre").isVisible()); -+ if (!wasOpen) await responses.last().locator("summary").click(); - } - await page.screenshot({ path: join(output, filename), fullPage: true }); - } - assert.deepEqual(errors, []); -- for (const name of SOURCE_FILES) assert.equal(sha256(join(current, name)), sourceHashes[name], 'Captured source changed during verification'); -+ for (const name of SOURCE_FILES) -+ assert.equal( -+ sha256(join(current, name)), -+ sourceHashes[name], -+ "Captured source changed during verification", -+ ); - const record = { -- ...checkpoint, startedAt, recordedAt: new Date().toISOString(), -- packageVersion: JSON.parse(readFileSync(join(current, 'node_modules/openiap-commerce-protocol/package.json'), 'utf8')).version, -- task: readFileSync(join(current, 'ai-task.md'), 'utf8'), sourceHashes, -- checks, history: lab.scenario.history, screenshot: 'screen.png', -- scope: 'Reviewed source checkpoint adapted from the Commerce Lab prototype. HTTP, SQLite and signatures execute locally; store and clock are fixtures. No full profile claim.', -+ ...checkpoint, -+ startedAt, -+ recordedAt: new Date().toISOString(), -+ packageVersion: JSON.parse( -+ readFileSync( -+ join(current, "node_modules/openiap-commerce-protocol/package.json"), -+ "utf8", -+ ), -+ ).version, -+ task: readFileSync(join(current, "ai-task.md"), "utf8"), -+ sourceHashes, -+ checks, -+ history: lab.scenario.history, -+ 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.", - }; -- writeFileSync(join(output, 'run.json'), JSON.stringify(record, null, 2) + '\n'); -- console.log(`Captured ${checkpoint.id}: ${checks.length} checks, ${SOURCE_FILES.length} source files.`); -+ writeFileSync( -+ join(output, "run.json"), -+ JSON.stringify(record, null, 2) + "\n", -+ ); -+ console.log( -+ `Captured ${checkpoint.id}: ${checks.length} checks, ${SOURCE_FILES.length} source files.`, -+ ); - } catch (error) { -- writeFileSync(join(output, `failure-${attempt}.txt`), `${new Date().toISOString()}\n${sanitize(error.stack)}\n`); -+ writeFileSync( -+ join(output, `failure-${attempt}.txt`), -+ `${new Date().toISOString()}\n${sanitize(error.stack)}\n`, -+ ); - throw error; - } finally { - await browser?.close(); -diff --git a/checkpoint-tools.mjs b/checkpoint-tools.mjs -index d620ac1..2e45db8 100644 ---- a/checkpoint-tools.mjs -+++ b/checkpoint-tools.mjs -@@ -1,53 +1,130 @@ --import assert from 'node:assert/strict'; --import { createHash } from 'node:crypto'; --import { spawnSync } from 'node:child_process'; --import { cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; --import { tmpdir } from 'node:os'; --import { join } from 'node:path'; -+import assert from "node:assert/strict"; -+import { createHash } from "node:crypto"; -+import { spawnSync } from "node:child_process"; -+import { -+ cpSync, -+ mkdirSync, -+ mkdtempSync, -+ readFileSync, -+ readdirSync, -+ rmSync, -+ writeFileSync, -+} from "node:fs"; -+import { tmpdir, homedir } from "node:os"; -+import { join } from "node:path"; - - export const SOURCE_FILES = [ -- '.gitignore', 'LICENSE', 'README.md', 'BUILD.md', 'ai-task.md', 'checkpoint.json', -- 'package.json', 'package-lock.json', 'contract.mjs', 'provider.mjs', 'webhooks.mjs', -- 'scenario.mjs', 'server.mjs', 'verify.mjs', 'dashboard.html', 'capture.mjs', -- 'export-docs.mjs', 'checkpoint-tools.mjs', 'checkpoint-tools.test.mjs', 'verify-checkpoints.mjs', -+ ".gitignore", -+ "LICENSE", -+ "README.md", -+ "BUILD.md", -+ "ai-task.md", -+ "checkpoint.json", -+ "package.json", -+ "package-lock.json", -+ "contract.mjs", -+ "provider.mjs", -+ "webhooks.mjs", -+ "consumer.mjs", -+ "scenario.mjs", -+ "server.mjs", -+ "verify.mjs", -+ "dashboard.html", -+ "capture.mjs", -+ "export-docs.mjs", -+ "checkpoint-tools.mjs", -+ "checkpoint-tools.test.mjs", -+ "verify-checkpoints.mjs", - ]; --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 const sanitize = text => text.replaceAll(process.cwd(), '').replace(/\/[^\s"']*\/commerce-(?:capture|patch|verify)-[^\s/"']+/g, ''); -+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 sanitize(text) { -+ const roots = [process.cwd(), import.meta.dir, homedir()] -+ .filter(Boolean) -+ .sort((a, b) => b.length - a.length); -+ let result = text; -+ for (const root of roots) result = result.replaceAll(root, ""); -+ return result.replace( -+ /\/[^\s"']*\/commerce-(?:capture|patch|verify|lab|compare|consumer)-[^\s/"']+/g, -+ "", -+ ); -+} -+export function nextAttempt(directory) { -+ return ( -+ Math.max( -+ 0, -+ ...readdirSync(directory).map((name) => -+ Number(/^(?:attempt|failure)-(\d+)\.txt$/.exec(name)?.[1] ?? 0), -+ ), -+ ) + 1 -+ ); -+} - export function run(command, args, cwd) { -- const result = spawnSync(command, args, { cwd, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }); -- const output = sanitize((result.stdout ?? '') + (result.stderr ?? '')); -- assert.equal(result.status, 0, `${command} ${args.join(' ')}\n${output}`); -- return { command: [command, ...args].join(' '), exitCode: result.status, output }; -+ const result = spawnSync(command, args, { -+ cwd, -+ encoding: "utf8", -+ maxBuffer: 20 * 1024 * 1024, -+ }); -+ const output = sanitize((result.stdout ?? "") + (result.stderr ?? "")); -+ assert.equal(result.status, 0, `${command} ${args.join(" ")}\n${output}`); -+ return { -+ command: [command, ...args].join(" "), -+ exitCode: result.status, -+ output, -+ }; - } - export function extract(archive, target) { - mkdirSync(target, { recursive: true }); -- run('tar', ['-xzf', archive, '-C', target]); -+ run("tar", ["-xzf", archive, "-C", target]); - } - export function normalizePatch(patch) { -- return patch.split('\n').map(line => { -- if (/^(diff --git |--- a\/|\+\+\+ b\/)/.test(line)) { -- return line.replace(/([ab])\/(?:before|after)\//g, '$1/'); -- } -- return line; -- }).join('\n'); -+ return patch -+ .split("\n") -+ .map((line) => { -+ if (/^(diff --git |--- a\/|\+\+\+ b\/)/.test(line)) { -+ return line.replace(/([ab])\/(?:before|after)\//g, "$1/"); -+ } -+ return line; -+ }) -+ .join("\n"); - } - export function createPatch(before, after) { -- const temp = mkdtempSync(join(tmpdir(), 'commerce-patch-')); -+ const temp = mkdtempSync(join(tmpdir(), "commerce-patch-")); - try { -- cpSync(before, join(temp, 'before'), { recursive: true }); -- cpSync(after, join(temp, 'after'), { recursive: true }); -- const result = spawnSync('git', ['diff', '--no-index', '--no-ext-diff', '--', 'before', 'after'], { cwd: temp, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }); -+ cpSync(before, join(temp, "before"), { recursive: true }); -+ cpSync(after, join(temp, "after"), { recursive: true }); -+ const result = spawnSync( -+ "git", -+ ["diff", "--no-index", "--no-ext-diff", "--", "before", "after"], -+ { cwd: temp, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }, -+ ); - assert([0, 1].includes(result.status), result.stderr); - const patch = normalizePatch(result.stdout); -- writeFileSync(join(temp, 'changes.patch'), patch); -- cpSync(before, join(temp, 'applied'), { recursive: true }); -- if (patch) run('git', ['apply', join(temp, 'changes.patch')], join(temp, 'applied')); -- assert.deepEqual(hashes(join(temp, 'applied')), hashes(after), 'Patch must reproduce the source archive exactly'); -+ writeFileSync(join(temp, "changes.patch"), patch); -+ cpSync(before, join(temp, "applied"), { recursive: true }); -+ if (patch) -+ run("git", ["apply", join(temp, "changes.patch")], join(temp, "applied")); -+ assert.deepEqual( -+ hashes(join(temp, "applied")), -+ hashes(after), -+ "Patch must reproduce the source archive exactly", -+ ); - return patch; -- } finally { rmSync(temp, { recursive: true, force: true }); } -+ } finally { -+ rmSync(temp, { recursive: true, force: true }); -+ } - } - export function readRecords(directory) { -- return readdirSync(directory).filter(name => /^\d\d-[\w-]+$/.test(name)).sort() -- .map(name => JSON.parse(readFileSync(join(directory, name, 'run.json'), 'utf8'))); -+ return readdirSync(directory) -+ .filter((name) => /^\d\d-[\w-]+$/.test(name)) -+ .sort() -+ .map((name) => -+ JSON.parse(readFileSync(join(directory, name, "run.json"), "utf8")), -+ ); - } -diff --git a/checkpoint-tools.test.mjs b/checkpoint-tools.test.mjs -index d467420..bc1c224 100644 ---- a/checkpoint-tools.test.mjs -+++ b/checkpoint-tools.test.mjs -@@ -1,19 +1,47 @@ --import assert from 'node:assert/strict'; --import { test } from 'node:test'; --import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; --import { tmpdir } from 'node:os'; --import { join } from 'node:path'; --import { createPatch } from './checkpoint-tools.mjs'; -+import assert from "node:assert/strict"; -+import { test } from "bun:test"; -+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -+import { tmpdir, homedir } from "node:os"; -+import { join } from "node:path"; -+import { createPatch, nextAttempt, sanitize } from "./checkpoint-tools.mjs"; - --test('patches preserve path-like source text across additions, changes, and deletions', () => { -- const temp = mkdtempSync(join(tmpdir(), 'commerce-patch-test-')); -+test("patches preserve path-like source text across additions, changes, and deletions", () => { -+ const temp = mkdtempSync(join(tmpdir(), "commerce-patch-test-")); - try { -- const before = join(temp, 'before'), after = join(temp, 'after'); -- mkdirSync(before); mkdirSync(after); -- writeFileSync(join(after, 'capture.mjs'), "const paths = ['a/before/', 'b/after/'];\n"); -- assert.match(createPatch(before, after), /\+const paths = \['a\/before\/', 'b\/after\/'\];/); -- writeFileSync(join(before, 'capture.mjs'), 'old\n'); -- writeFileSync(join(before, 'removed.md'), 'remove\n'); -+ const before = join(temp, "before"), -+ after = join(temp, "after"); -+ mkdirSync(before); -+ mkdirSync(after); -+ writeFileSync( -+ join(after, "capture.mjs"), -+ "const paths = ['a/before/', 'b/after/'];\n", -+ ); -+ assert.match( -+ createPatch(before, after), -+ /\+const paths = \['a\/before\/', 'b\/after\/'\];/, -+ ); -+ writeFileSync(join(before, "capture.mjs"), "old\n"); -+ writeFileSync(join(before, "removed.md"), "remove\n"); - assert.match(createPatch(before, after), /deleted file mode/); -- } finally { rmSync(temp, { recursive: true, force: true }); } -+ } finally { -+ rmSync(temp, { recursive: true, force: true }); -+ } -+}); -+ -+test("failed attempts get new numbers and private paths are redacted", () => { -+ const temp = mkdtempSync(join(tmpdir(), "commerce-capture-test-")); -+ try { -+ writeFileSync(join(temp, "failure-1.txt"), "first failure"); -+ assert.equal(nextAttempt(temp), 2); -+ writeFileSync(join(temp, "failure-2.txt"), "second failure"); -+ assert.equal(nextAttempt(temp), 3); -+ const output = sanitize( -+ `${homedir()}/.npm/_logs/debug.log ${import.meta.dir}/capture.mjs ${temp}/source.mjs`, -+ ); -+ assert(!output.includes(homedir())); -+ assert(!output.includes(import.meta.dir)); -+ assert(!output.includes(temp)); -+ } finally { -+ rmSync(temp, { recursive: true, force: true }); -+ } - }); -diff --git a/checkpoint.json b/checkpoint.json -index 576f357..d416197 100644 ---- a/checkpoint.json -+++ b/checkpoint.json -@@ -1,8 +1,8 @@ - { - "step": 6, -- "id": "06-recover-reviewed", -+ "id": "06-recover-reviewed-2", - "title": "Expire access and restart", - "built": "Expiry, atomic grant events, recovery, and verified source archives", - "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", -- "previous": "06-recover" -+ "previous": "06-recover-reviewed" - } -diff --git a/consumer.mjs b/consumer.mjs -new file mode 100644 -index 0000000..e4373eb ---- /dev/null -+++ b/consumer.mjs -@@ -0,0 +1,206 @@ -+import assert from "node:assert/strict"; -+import { randomBytes, randomUUID, createHash } from "node:crypto"; -+import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; -+import { tmpdir } from "node:os"; -+import { join, resolve } from "node:path"; -+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. -+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); -+ const server = Bun.serve({ -+ hostname: "127.0.0.1", -+ port, -+ maxRequestBodySize: 64 * 1024, -+ fetch(request) { -+ const url = new URL(request.url); -+ const origin = request.headers.get("origin"); -+ const expected = `http://127.0.0.1:${server.port}`; -+ if (url.origin !== expected || (origin && origin !== expected)) -+ return new Response("Local requests only", { status: 403 }); -+ if (request.method === "POST" && url.pathname === "/webhooks/commerce") -+ return receiver.fetch(request); -+ if (request.method === "GET" && url.pathname === "/health") -+ return Response.json({ ready: true }); -+ return new Response("Not found", { status: 404 }); -+ }, -+ }); -+ return { -+ url: `http://127.0.0.1:${server.port}/webhooks/commerce`, -+ count: () => receiver.count(), -+ reopen() { -+ receiver.close(); -+ receiver = createReceiver(path, secret, now); -+ }, -+ async close() { -+ await server.stop(true); -+ receiver.close(); -+ }, -+ }; -+} -+ -+export async function runConsumerDemo() { -+ const directory = mkdtempSync(join(tmpdir(), "commerce-consumer-")); -+ const secret = randomBytes(32).toString("hex"); -+ const now = Date.now(); -+ const consumer = startConsumer({ -+ secret, -+ path: join(directory, "inbox.sqlite"), -+ now: () => now, -+ }); -+ const checks = [], -+ results = []; -+ const check = (name, actual, expected) => { -+ assert.deepEqual(actual, expected, name); -+ checks.push(name); -+ }; -+ const lifecycle = [ -+ ["subscription.started", "Active", true], -+ ["entitlement.granted", "Active", true], -+ ["subscription.renewed", "Active", true], -+ ["subscription.canceled", "Active", true], -+ ["subscription.expired", "Expired", false], -+ ["entitlement.revoked", "Expired", false], -+ ["subscription.refunded", "Refunded", false], -+ ]; -+ try { -+ for (const [index, [eventType, state, active]] of lifecycle.entries()) { -+ const event = { -+ eventId: randomUUID(), -+ eventType, -+ eventVersion: COMMERCE_EVENT_VERSION, -+ occurredAt: now - 60_000 + index * 1000, -+ processedAt: now, -+ store: "fixture", -+ environment: "local-fixture", -+ projectId: "demo_project", -+ userId: "demo_user", -+ productId: "premium.monthly", -+ ...(["subscription.started", "subscription.renewed"].includes(eventType) -+ ? { -+ price: { -+ currency: "USD", -+ amountMicros: 9990000, -+ provenance: "store", -+ }, -+ } -+ : {}), -+ subscription: { -+ productId: "premium.monthly", -+ state, -+ active, -+ expiresAt: active ? now + 60_000 : now - 1000, -+ willRenew: active && eventType !== "subscription.canceled", -+ }, -+ }; -+ assert(validate("#/$defs/CommerceEvent", event)); -+ const body = JSON.stringify(event); -+ const timestamp = String(Math.floor(now / 1000)); -+ const headers = { -+ "content-type": WEBHOOK.contentType, -+ [WEBHOOK.timestampHeader]: timestamp, -+ [WEBHOOK.signatureHeader]: sign(secret, timestamp, body), -+ [WEBHOOK.eventIdHeader]: event.eventId, -+ [WEBHOOK.deliveryIdHeader]: randomUUID(), -+ }; -+ const response = await fetch(consumer.url, { -+ method: "POST", -+ headers, -+ body, -+ }); -+ const result = await response.json(); -+ check( -+ `${eventType}: authenticated and saved`, -+ [response.status, result.duplicate], -+ [200, false], -+ ); -+ const duplicate = await fetch(consumer.url, { -+ method: "POST", -+ headers, -+ body, -+ }); -+ check( -+ `${eventType}: redelivery deduplicated`, -+ (await duplicate.json()).duplicate, -+ true, -+ ); -+ const tampered = await fetch(consumer.url, { -+ method: "POST", -+ headers, -+ body: body + " ", -+ }); -+ check(`${eventType}: tampering rejected`, tampered.status, 401); -+ results.push({ -+ event, -+ httpStatus: response.status, -+ duplicate: true, -+ tamperedHttpStatus: tampered.status, -+ }); -+ } -+ check("One inbox record per event", consumer.count(), lifecycle.length); -+ consumer.reopen(); -+ check( -+ "Inbox survives reopening SQLite", -+ consumer.count(), -+ lifecycle.length, -+ ); -+ return { -+ recordedAt: new Date().toISOString(), -+ 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, -+ results, -+ inboxCount: consumer.count(), -+ }; -+ } finally { -+ await consumer.close(); -+ rmSync(directory, { recursive: true, force: true }); -+ } -+} -+ -+if (import.meta.main) { -+ if (process.argv[2] === "demo") { -+ const report = await runConsumerDemo(); -+ if (process.argv[3] === "--record") { -+ assert(process.argv[4], "Provide a report path"); -+ report.sourceHashes = Object.fromEntries( -+ [ -+ "consumer.mjs", -+ "webhooks.mjs", -+ "contract.mjs", -+ "package.json", -+ "package-lock.json", -+ ].map((name) => [ -+ name, -+ createHash("sha256") -+ .update(readFileSync(join(import.meta.dir, name))) -+ .digest("hex"), -+ ]), -+ ); -+ writeFileSync( -+ resolve(process.argv[4]), -+ JSON.stringify(report, null, 2) + "\n", -+ ); -+ } -+ console.log( -+ `Receiver ready: ${report.inboxCount} events saved once; ${report.checks.length} checks passed. No external service contacted.`, -+ ); -+ } else { -+ const consumer = startConsumer({ -+ secret: process.env.COMMERCE_WEBHOOK_SECRET, -+ path: resolve(process.env.COMMERCE_INBOX_PATH ?? "consumer.sqlite"), -+ port: 5182, -+ }); -+ console.log( -+ `Receiver: ${consumer.url}\nPersisted inbox: ${resolve(process.env.COMMERCE_INBOX_PATH ?? "consumer.sqlite")}`, -+ ); -+ for (const signal of ["SIGINT", "SIGTERM"]) -+ process.on(signal, async () => { -+ await consumer.close(); -+ process.exit(0); -+ }); -+ } -+} -diff --git a/export-docs.mjs b/export-docs.mjs -index 82cf485..731e40d 100644 ---- a/export-docs.mjs -+++ b/export-docs.mjs -@@ -1,51 +1,112 @@ --import assert from 'node:assert/strict'; --import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; --import { tmpdir } from 'node:os'; --import { join, resolve } from 'node:path'; --import { SOURCE_FILES, extract, hashes, readRecords, sha256 } from './checkpoint-tools.mjs'; -+import assert from "node:assert/strict"; -+import { -+ cpSync, -+ mkdirSync, -+ mkdtempSync, -+ readFileSync, -+ rmSync, -+ writeFileSync, -+} from "node:fs"; -+import { tmpdir } from "node:os"; -+import { join, resolve } from "node:path"; -+import { -+ SOURCE_FILES, -+ extract, -+ hashes, -+ readRecords, -+ sha256, -+} from "./checkpoint-tools.mjs"; - - const target = process.argv[2]; --assert(target, 'Usage: bun export-docs.mjs '); -+assert(target, "Usage: bun export-docs.mjs "); - const output = resolve(target); --const source = join(import.meta.dir, 'docs/build'); --const guide = JSON.parse(readFileSync(join(source, 'guide.json'), 'utf8')); -+const source = join(import.meta.dir, "docs/build"); -+const guide = JSON.parse(readFileSync(join(source, "guide.json"), "utf8")); - const records = readRecords(source); --const selected = guide.map(step => { -- const record = records.find(record => record.id === step.id); -+const selected = guide.map((step) => { -+ const record = records.find((record) => record.id === step.id); - assert(record, `Missing checkpoint: ${step.id}`); - return record; - }); - const last = selected.at(-1); - assert.equal(selected.length, 6); --assert.deepEqual(Object.fromEntries(SOURCE_FILES.sort().map(name => [name, sha256(join(import.meta.dir, name))])), last.sourceHashes, 'Capture the final source before exporting'); --const verification = JSON.parse(readFileSync(join(source, 'verification.json'), 'utf8')); -+assert.deepEqual( -+ Object.fromEntries( -+ SOURCE_FILES.sort().map((name) => [ -+ name, -+ sha256(join(import.meta.dir, name)), -+ ]), -+ ), -+ last.sourceHashes, -+ "Capture the final source before exporting", -+); -+const verification = JSON.parse( -+ readFileSync(join(source, "verification.json"), "utf8"), -+); - for (const record of records) { -- const result = verification.results.find(result => result.id === record.id); -- assert(result?.sourceHashesMatch && result.patchAppliesExactly, `Verify ${record.id} before export`); -- assert.equal(result.archiveSha256, sha256(join(source, record.id, 'source.tar.gz'))); -- assert.equal(result.patchSha256, sha256(join(source, record.id, 'changes.patch'))); -+ const result = verification.results.find((result) => result.id === record.id); -+ assert( -+ result?.sourceHashesMatch && result.patchAppliesExactly, -+ `Verify ${record.id} before export`, -+ ); -+ assert.equal( -+ result.archiveSha256, -+ sha256(join(source, record.id, "source.tar.gz")), -+ ); -+ assert.equal( -+ result.patchSha256, -+ sha256(join(source, record.id, "changes.patch")), -+ ); - } -+const consumerReport = JSON.parse( -+ readFileSync(join(source, "consumer-run.json"), "utf8"), -+); -+for (const [name, digest] of Object.entries(consumerReport.sourceHashes)) -+ assert.equal( -+ digest, -+ last.sourceHashes[name], -+ "Record the consumer demo again before export", -+ ); - mkdirSync(output, { recursive: true }); --for (const record of records) cpSync(join(source, record.id), join(output, record.id), { recursive: true }); --for (const name of ['README.md', 'REVIEW.md', 'verification.json', '01-contract-first-attempt.txt']) cpSync(join(source, name), join(output, name)); -+for (const record of records) -+ cpSync(join(source, record.id), join(output, record.id), { recursive: true }); -+for (const name of [ -+ "README.md", -+ "REVIEW.md", -+ "verification.json", -+ "consumer-run.json", -+ "01-contract-first-attempt.txt", -+]) -+ cpSync(join(source, name), join(output, name)); - const report = { -- recordedAt: last.recordedAt, scope: last.scope, checks: last.checks, -+ recordedAt: last.recordedAt, -+ scope: last.scope, -+ checks: last.checks, - standalone: { version: last.packageVersion, passed: last.checks.length }, - milestones: selected.map((record, index) => ({ -- ...record.history.at(-1), screenshot: `${record.id}/screen.png`, -+ ...record.history.at(-1), -+ screenshot: `${record.id}/screen.png`, - build: { -- ...guide[index], source: `${record.id}/source.tar.gz`, -- changes: `${record.id}/changes.patch`, report: `${record.id}/run.json`, -- ...(record.previous ? { previousSource: `${record.previous}/source.tar.gz` } : {}), -+ ...guide[index], -+ source: `${record.id}/source.tar.gz`, -+ changes: `${record.id}/changes.patch`, -+ report: `${record.id}/run.json`, -+ ...(record.previous -+ ? { previousSource: `${record.previous}/source.tar.gz` } -+ : {}), - passed: record.checks.length, - }, - })), - }; --const temp = mkdtempSync(join(tmpdir(), 'commerce-verify-export-')); -+const temp = mkdtempSync(join(tmpdir(), "commerce-verify-export-")); - try { -- extract(join(source, last.id, 'source.tar.gz'), temp); -+ extract(join(source, last.id, "source.tar.gz"), temp); - assert.deepEqual(hashes(temp), last.sourceHashes); -- cpSync(join(temp, 'BUILD.md'), join(output, 'build-brief.md')); --} finally { rmSync(temp, { recursive: true, force: true }); } --writeFileSync(join(output, 'run.json'), JSON.stringify(report, null, 2) + '\n'); --console.log(`Exported ${selected.length} milestones and ${records.length} source revisions to ${output}`); -+ cpSync(join(temp, "BUILD.md"), join(output, "build-brief.md")); -+} finally { -+ rmSync(temp, { recursive: true, force: true }); -+} -+writeFileSync(join(output, "run.json"), JSON.stringify(report, null, 2) + "\n"); -+console.log( -+ `Exported ${selected.length} milestones and ${records.length} source revisions to ${output}`, -+); -diff --git a/package.json b/package.json -index d88e6bc..4ac31f6 100644 ---- a/package.json -+++ b/package.json -@@ -5,9 +5,12 @@ - "type": "module", - "scripts": { - "start": "bun server.mjs", -- "test": "bun verify.mjs && node --test checkpoint-tools.test.mjs", -+ "test": "bun verify.mjs", - "capture": "bun capture.mjs", -- "verify:checkpoints": "bun verify-checkpoints.mjs" -+ "verify:checkpoints": "bun verify-checkpoints.mjs", -+ "test:tooling": "bun test checkpoint-tools.test.mjs", -+ "consumer": "bun consumer.mjs", -+ "demo:consumer": "bun consumer.mjs demo" - }, - "dependencies": { - "openiap-commerce-protocol": "0.1.0", -diff --git a/provider.mjs b/provider.mjs -index ee3d047..c134436 100644 ---- a/provider.mjs -+++ b/provider.mjs -@@ -45,7 +45,7 @@ export function createProvider(path, now) { - CREATE TABLE IF NOT EXISTS purchases ( - fingerprint TEXT PRIMARY KEY, user_id TEXT, product_id TEXT NOT NULL, - state TEXT NOT NULL, expires_at INTEGER NOT NULL, will_renew INTEGER NOT NULL, -- observed_at INTEGER NOT NULL -+ observed_at INTEGER NOT NULL, entitlement_granted INTEGER NOT NULL DEFAULT 0 - ); - CREATE TABLE IF NOT EXISTS observations (id TEXT PRIMARY KEY); - CREATE TABLE IF NOT EXISTS outbox ( -@@ -90,10 +90,18 @@ export function createProvider(path, now) { - const capabilityNames = Object.keys( - providerCapabilitiesSchema.$defs.StoreCapabilities.properties, - ); -- const supported = new Set(["initialValidation", "subscriptions", "entitlements", "serverNotifications", "expiration"]); -+ const supported = new Set([ -+ "initialValidation", -+ "subscriptions", -+ "entitlements", -+ "serverNotifications", -+ "expiration", -+ ]); - const capabilities = { - specVersion: HTTP_BINDING.protocolVersion, -- implementation: { name: "Commerce Protocol Example — fictional fixture store" }, -+ implementation: { -+ name: "Commerce Protocol Example — fictional fixture store", -+ }, - eventTypes, - stores: { - fixture: Object.fromEntries( -@@ -112,17 +120,24 @@ export function createProvider(path, now) { - - function enqueue(eventType, row, occurredAt, sourceStoreEventId) { - const body = { -- eventId: randomUUID(), eventType, eventVersion: COMMERCE_EVENT_VERSION, -- occurredAt, processedAt: now(), store: FIXTURE.store, -- environment: "local-fixture", projectId: "commerce_lab", -+ eventId: randomUUID(), -+ eventType, -+ eventVersion: COMMERCE_EVENT_VERSION, -+ occurredAt, -+ processedAt: now(), -+ store: FIXTURE.store, -+ environment: "local-fixture", -+ projectId: "commerce_lab", - productId: row.product_id, - ...(row.user_id ? { userId: row.user_id } : {}), - subscription: snapshot(row), - ...(sourceStoreEventId ? { sourceStoreEventId } : {}), - }; -- if (!validate("#/$defs/CommerceEvent", body)) throw new Error("Invalid event"); -- db.query("INSERT INTO outbox (event_id, delivery_id, body) VALUES (?, ?, ?)") -- .run(body.eventId, randomUUID(), JSON.stringify(body)); -+ if (!validate("#/$defs/CommerceEvent", body)) -+ throw new Error("Invalid event"); -+ db.query( -+ "INSERT INTO outbox (event_id, delivery_id, body) VALUES (?, ?, ?)", -+ ).run(body.eventId, randomUUID(), JSON.stringify(body)); - } - - const handlers = { -@@ -132,7 +147,7 @@ export function createProvider(path, now) { - if (verdict.error) return verdict; - if (verdict.accepted) { - db.query( -- `INSERT OR IGNORE INTO purchases VALUES (?, NULL, ?, 'Active', ?, 1, ?)`, -+ `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, -@@ -142,8 +157,12 @@ export function createProvider(path, now) { - } - return { - store: FIXTURE.store, -- isValid: verdict.accepted, -- state: verdict.accepted ? "ENTITLED" : "INAUTHENTIC", -+ isValid: verdict.accepted && now() < FIXTURE.expiresAt, -+ state: !verdict.accepted -+ ? "INAUTHENTIC" -+ : now() >= FIXTURE.expiresAt -+ ? "EXPIRED" -+ : "ENTITLED", - ...(verdict.accepted ? { productId: FIXTURE.productId } : {}), - environment: "local-fixture", - }; -@@ -154,14 +173,19 @@ export function createProvider(path, now) { - return { error: "INVALID_REQUEST" }; - return db.transaction(() => { - const key = fingerprint(input.evidence); -- const updated = db.query( -- "UPDATE purchases SET user_id = ? WHERE fingerprint = ? AND user_id IS NULL", -- ).run(input.userId, key); -+ const updated = db -+ .query( -+ "UPDATE purchases SET user_id = ? WHERE fingerprint = ? AND user_id IS NULL", -+ ) -+ .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())) { -- enqueue("entitlement.granted", row, now()); -+ enqueue("entitlement.granted", row, row.observed_at); -+ db.query( -+ "UPDATE purchases SET entitlement_granted = 1 WHERE fingerprint = ?", -+ ).run(key); - } - return { bound: row?.user_id === input.userId }; - })(); -@@ -239,7 +263,6 @@ export function createProvider(path, now) { - (kind === "expire" && row.state === "Expired") - ) - return false; -- const wasActive = isEntitled(row.state, row.expires_at, occurredAt - 1); - const next = { - ...row, - will_renew: 0, -@@ -251,8 +274,15 @@ export function createProvider(path, now) { - const types = [ - kind === "cancel" ? "subscription.canceled" : "subscription.expired", - ]; -- if (kind === "expire" && wasActive && row.user_id) -+ if ( -+ row.entitlement_granted && -+ !isEntitled(next.state, next.expires_at, now()) -+ ) { - types.push("entitlement.revoked"); -+ db.query( -+ "UPDATE purchases SET entitlement_granted = 0 WHERE fingerprint = ?", -+ ).run(row.fingerprint); -+ } - for (const eventType of types) enqueue(eventType, next, occurredAt, id); - return true; - })(); -diff --git a/scenario.mjs b/scenario.mjs -index b229c15..525327c 100644 ---- a/scenario.mjs -+++ b/scenario.mjs -@@ -5,35 +5,37 @@ import { deliver } from "./webhooks.mjs"; - - export const STAGES = [ - { -- "title": "Start with the contract", -- "built": "HTTP routes + schema validation + SQLite", -- "result": "A running server, an empty purchase table, and no access." -+ title: "Start with the contract", -+ built: "HTTP routes + schema validation + SQLite", -+ result: "A running server, an empty purchase table, and no access.", - }, - { -- "title": "Verify a purchase", -- "built": "Fixture store adapter + purchase persistence", -- "result": "Valid evidence is saved. Alice still has no access." -+ title: "Verify a purchase", -+ built: "Fixture store adapter + purchase persistence", -+ result: "Valid evidence is saved. Alice still has no access.", - }, - { -- "title": "Connect it to a user", -- "built": "Server authorization + atomic binding + entitlement reads", -- "result": "Alice gets Premium. Another user cannot take the purchase." -+ title: "Connect it to a user", -+ built: "Server authorization + atomic binding + entitlement reads", -+ result: "Alice gets Premium. Another user cannot take the purchase.", - }, - { -- "title": "Handle cancellation", -- "built": "Lifecycle processing + transactional event outbox", -- "result": "Renewal stops. Alice keeps the time she already paid for." -+ title: "Handle cancellation", -+ built: "Lifecycle processing + transactional event outbox", -+ result: "Renewal stops. Alice keeps the time she already paid for.", - }, - { -- "title": "Deliver, retry, deduplicate", -- "built": "HMAC signatures + retry worker + durable receiver inbox", -- "result": "A 503 retries successfully. Redelivery creates no second inbox row." -+ title: "Deliver, retry, deduplicate", -+ built: "HMAC signatures + retry worker + durable receiver inbox", -+ result: -+ "A 503 retries successfully. Redelivery creates no second inbox row.", - }, - { -- "title": "Expire access and restart", -- "built": "Expiry-aware reads + recovery from SQLite", -- "result": "Access closes at the deadline. Restarting preserves purchases and deliveries." -- } -+ title: "Expire access and restart", -+ built: "Expiry-aware reads + recovery from SQLite", -+ result: -+ "Access closes at the deadline. Restarting preserves purchases and deliveries.", -+ }, - ]; - - export async function requestOperation(baseUrl, name, input, role = "server") { -@@ -79,14 +81,26 @@ export function createScenario(runtime) { - }; - const start = checks.length; - if (stage === 0) { -- check("Fixture request matches the installed schema", validate(operation("verifyPurchase").input, evidence), true); -+ check( -+ "Fixture request matches the installed schema", -+ validate(operation("verifyPurchase").input, evidence), -+ true, -+ ); - check( - "Capabilities use the published response schema", - (await run("providerCapabilities", null, null)).httpStatus, - 200, - ); -- check("Purchase storage starts empty", runtime.provider.inspect().purchases, []); -- check("No profile conformance is claimed", (await run("providerCapabilities", null, null)).body.profiles, undefined); -+ check( -+ "Purchase storage starts empty", -+ runtime.provider.inspect().purchases, -+ [], -+ ); -+ check( -+ "No profile conformance is claimed", -+ (await run("providerCapabilities", null, null)).body.profiles, -+ undefined, -+ ); - } else if (stage === 1) { - check( - "Fixture evidence is accepted", -@@ -155,9 +169,11 @@ export function createScenario(runtime) { - (await run("entitlements", { userId: FIXTURE.userId })).body.productIds, - [FIXTURE.productId], - ); -- check("First binding queues one grant; repeat and conflict queue none", -- runtime.provider.inspect().deliveries.map(row => row.eventType), -- ["entitlement.granted"]); -+ check( -+ "First binding queues one grant; repeat and conflict queue none", -+ runtime.provider.inspect().deliveries.map((row) => row.eventType), -+ ["entitlement.granted"], -+ ); - } else if (stage === 3) { - runtime.time = FIXTURE.startsAt + 86_400_000; - runtime.provider.observe({ -@@ -176,7 +192,10 @@ export function createScenario(runtime) { - ); - check( - "Cancellation queues one event", -- runtime.provider.inspect().deliveries.filter(row => row.eventType === "subscription.canceled").length, -+ runtime.provider -+ .inspect() -+ .deliveries.filter((row) => row.eventType === "subscription.canceled") -+ .length, - 1, - ); - } else if (stage === 4) { -diff --git a/verify-checkpoints.mjs b/verify-checkpoints.mjs -index 98a6736..bd6b925 100644 ---- a/verify-checkpoints.mjs -+++ b/verify-checkpoints.mjs -@@ -1,28 +1,84 @@ --import assert from 'node:assert/strict'; --import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; --import { tmpdir } from 'node:os'; --import { join, resolve } from 'node:path'; --import { extract, hashes, readRecords, run, sha256 } from './checkpoint-tools.mjs'; -+import assert from "node:assert/strict"; -+import { -+ mkdirSync, -+ mkdtempSync, -+ readFileSync, -+ rmSync, -+ writeFileSync, -+} from "node:fs"; -+import { tmpdir } from "node:os"; -+import { join, resolve } from "node:path"; -+import { -+ extract, -+ hashes, -+ readRecords, -+ run, -+ sha256, -+} from "./checkpoint-tools.mjs"; - --const source = resolve(process.argv[2] ?? join(import.meta.dir, 'docs/build')); --const temp = mkdtempSync(join(tmpdir(), 'commerce-verify-')); -+const source = resolve(process.argv[2] ?? join(import.meta.dir, "docs/build")); -+const temp = mkdtempSync(join(tmpdir(), "commerce-verify-")); - const records = readRecords(source); - const results = []; - try { -- const applied = join(temp, 'applied'); -+ const applied = join(temp, "applied"); - mkdirSync(applied); - for (const record of records) { - const directory = join(source, record.id); -- const archive = join(directory, 'source.tar.gz'); -+ const archive = join(directory, "source.tar.gz"); - const consumer = join(temp, record.id); - extract(archive, consumer); -- assert.deepEqual(hashes(consumer), record.sourceHashes, `${record.id}: archive hashes`); -- const patch = join(directory, 'changes.patch'); -- if (readFileSync(patch, 'utf8')) run('git', ['apply', patch], applied); -- assert.deepEqual(hashes(applied), record.sourceHashes, `${record.id}: patch chain from empty`); -- const commands = [run('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], consumer), run('npm', ['test'], consumer)]; -- results.push({ id: record.id, archiveSha256: sha256(archive), patchSha256: sha256(patch), sourceHashesMatch: true, patchAppliesExactly: true, commands }); -- console.log(`Verified ${record.id}: extracted source, patch chain, npm ci, npm test`); -+ assert.deepEqual( -+ hashes(consumer), -+ record.sourceHashes, -+ `${record.id}: archive hashes`, -+ ); -+ const patch = join(directory, "changes.patch"); -+ if (readFileSync(patch, "utf8")) run("git", ["apply", patch], applied); -+ assert.deepEqual( -+ hashes(applied), -+ record.sourceHashes, -+ `${record.id}: patch chain from empty`, -+ ); -+ const commands = [ -+ run( -+ "npm", -+ ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], -+ consumer, -+ ), -+ run("npm", ["test"], consumer), -+ ]; -+ if ( -+ JSON.parse(readFileSync(join(consumer, "package.json"), "utf8")).scripts[ -+ "test:tooling" -+ ] -+ ) -+ commands.push(run("npm", ["run", "test:tooling"], consumer)); -+ results.push({ -+ id: record.id, -+ archiveSha256: sha256(archive), -+ patchSha256: sha256(patch), -+ sourceHashesMatch: true, -+ patchAppliesExactly: true, -+ commands, -+ }); -+ console.log( -+ `Verified ${record.id}: extracted source, patch chain, npm ci, npm test`, -+ ); - } -- writeFileSync(join(source, 'verification.json'), JSON.stringify({ recordedAt: new Date().toISOString(), scope: 'Each recorded archive extracted outside both repositories; patches applied in order from an empty directory; published dependencies installed with npm.', results }, null, 2) + '\n'); --} finally { rmSync(temp, { recursive: true, force: true }); } -+ writeFileSync( -+ join(source, "verification.json"), -+ JSON.stringify( -+ { -+ recordedAt: new Date().toISOString(), -+ scope: -+ "Each recorded archive extracted outside both repositories; patches applied in order from an empty directory; published dependencies installed with npm.", -+ results, -+ }, -+ null, -+ 2, -+ ) + "\n", -+ ); -+} finally { -+ rmSync(temp, { recursive: true, force: true }); -+} -diff --git a/verify.mjs b/verify.mjs -index 234beb9..bc77b7a 100644 ---- a/verify.mjs -+++ b/verify.mjs -@@ -4,6 +4,7 @@ import vectors from "openiap-commerce-protocol/vectors/signatures.json"; - import { SUBSCRIPTION_STATES, WEBHOOK } from "openiap-commerce-protocol"; - import { FIXTURE, isEntitled } from "./provider.mjs"; - import { requestOperation } from "./scenario.mjs"; -+import { runConsumerDemo } from "./consumer.mjs"; - import { startLab } from "./server.mjs"; - import { authentic, deliver, sign } from "./webhooks.mjs"; - -@@ -107,6 +108,15 @@ export async function verifyLab({ compareSigner } = {}) { - }), - false, - ); -+ const expiredVerdict = await call("verifyPurchase", { -+ store: FIXTURE.store, -+ evidence: FIXTURE.evidence, -+ }); -+ check( -+ "Expired fixture evidence has an expired verdict", -+ [expiredVerdict.body.isValid, expiredVerdict.body.state], -+ [false, "EXPIRED"], -+ ); - const body = lab.runtime.provider.db - .query("SELECT body FROM outbox LIMIT 1") - .get().body; -@@ -158,13 +168,38 @@ export async function verifyLab({ compareSigner } = {}) { - bindings.filter((row) => row.body.bound).length, - 1, - ); -- check("A cancellation older than the active row is ignored", -- fresh.runtime.provider.observe({ id: "old-active-cancel", kind: "cancel", occurredAt: FIXTURE.startsAt - 1 }), false); -- check("Ignoring an old cancellation preserves renewal", -- fresh.runtime.provider.inspect().purchases[0].willRenew, 1); -- assert.throws(() => fresh.runtime.provider.observe({ id: "early-expiry", kind: "expire", occurredAt: FIXTURE.startsAt }), /reconciliation/); -- check("Conflicting expiry is not consumed", -- fresh.runtime.provider.db.query("SELECT COUNT(*) AS count FROM observations WHERE id = 'early-expiry'").get().count, 0); -+ check( -+ "A cancellation older than the active row is ignored", -+ fresh.runtime.provider.observe({ -+ id: "old-active-cancel", -+ kind: "cancel", -+ occurredAt: FIXTURE.startsAt - 1, -+ }), -+ false, -+ ); -+ check( -+ "Ignoring an old cancellation preserves renewal", -+ fresh.runtime.provider.inspect().purchases[0].willRenew, -+ 1, -+ ); -+ assert.throws( -+ () => -+ fresh.runtime.provider.observe({ -+ id: "early-expiry", -+ kind: "expire", -+ occurredAt: FIXTURE.startsAt, -+ }), -+ /reconciliation/, -+ ); -+ check( -+ "Conflicting expiry is not consumed", -+ fresh.runtime.provider.db -+ .query( -+ "SELECT COUNT(*) AS count FROM observations WHERE id = 'early-expiry'", -+ ) -+ .get().count, -+ 0, -+ ); - fresh.runtime.time += 1000; - const observation = { - id: "atomic-cancel", -@@ -206,20 +241,90 @@ export async function verifyLab({ compareSigner } = {}) { - } - const binding = startLab(); - try { -- const call = (name, input) => requestOperation(binding.runtime.baseUrl, name, input); -+ const call = (name, input) => -+ requestOperation(binding.runtime.baseUrl, name, input); - const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence }; - await call("verifyPurchase", evidence); -- binding.runtime.provider.db.exec("CREATE TRIGGER fail_grant BEFORE INSERT ON outbox BEGIN SELECT RAISE(ABORT, 'injected disk failure'); END;"); -- check("Grant failure rejects binding", (await call("bindPurchase", { ...evidence, userId: FIXTURE.userId })).httpStatus, 500); -- check("Grant failure rolls back ownership", binding.runtime.provider.inspect().purchases[0].userId, null); -+ binding.runtime.provider.db.exec( -+ "CREATE TRIGGER fail_grant BEFORE INSERT ON outbox BEGIN SELECT RAISE(ABORT, 'injected disk failure'); END;", -+ ); -+ check( -+ "Grant failure rejects binding", -+ (await call("bindPurchase", { ...evidence, userId: FIXTURE.userId })) -+ .httpStatus, -+ 500, -+ ); -+ check( -+ "Grant failure rolls back ownership", -+ binding.runtime.provider.inspect().purchases[0].userId, -+ null, -+ ); - binding.runtime.provider.db.exec("DROP TRIGGER fail_grant"); - binding.runtime.time = FIXTURE.expiresAt; -- check("An expired purchase can be bound", (await call("bindPurchase", { ...evidence, userId: FIXTURE.userId })).body.bound, true); -- check("Binding expired evidence emits no grant", binding.runtime.provider.inspect().deliveries, []); -+ check( -+ "An expired purchase can be bound", -+ (await call("bindPurchase", { ...evidence, userId: FIXTURE.userId })).body -+ .bound, -+ true, -+ ); -+ check( -+ "Binding expired evidence emits no grant", -+ binding.runtime.provider.inspect().deliveries, -+ [], -+ ); - } finally { - await binding.close(); - rmSync(binding.directory, { recursive: true, force: true }); - } -+ const delayed = startLab(); -+ try { -+ const call = (name, input) => -+ requestOperation(delayed.runtime.baseUrl, name, input); -+ const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence }; -+ await call("verifyPurchase", evidence); -+ delayed.runtime.time += 3_600_000; -+ await call("bindPurchase", { ...evidence, userId: FIXTURE.userId }); -+ const grant = JSON.parse( -+ delayed.runtime.provider.db.query("SELECT body FROM outbox LIMIT 1").get() -+ .body, -+ ); -+ check( -+ "Delayed binding retains the store occurrence", -+ grant.occurredAt, -+ FIXTURE.startsAt, -+ ); -+ check( -+ "Delayed binding records its actual processing time", -+ grant.processedAt, -+ delayed.runtime.time, -+ ); -+ delayed.runtime.restart(); -+ delayed.runtime.time = FIXTURE.expiresAt + 60_000; -+ delayed.runtime.provider.observe({ -+ id: "late-expiry", -+ kind: "expire", -+ occurredAt: delayed.runtime.time, -+ }); -+ check( -+ "Late expiry revokes a persisted grant exactly once", -+ delayed.runtime.provider.inspect().deliveries.map((row) => row.eventType), -+ ["entitlement.granted", "subscription.expired", "entitlement.revoked"], -+ ); -+ delayed.runtime.provider.observe({ -+ id: "another-expiry", -+ kind: "expire", -+ occurredAt: delayed.runtime.time, -+ }); -+ check( -+ "Repeated expiry emits no second revocation", -+ delayed.runtime.provider.inspect().deliveries.length, -+ 3, -+ ); -+ } finally { -+ await delayed.close(); -+ rmSync(delayed.directory, { recursive: true, force: true }); -+ } -+ checks.push(...(await runConsumerDemo()).checks); - return checks; - } - diff --git a/docs/build/06-recover-reviewed-2/mobile.png b/docs/build/06-recover-reviewed-2/mobile.png deleted file mode 100644 index 7e835b9..0000000 Binary files a/docs/build/06-recover-reviewed-2/mobile.png and /dev/null differ diff --git a/docs/build/06-recover-reviewed-2/run.json b/docs/build/06-recover-reviewed-2/run.json deleted file mode 100644 index 782e12c..0000000 --- a/docs/build/06-recover-reviewed-2/run.json +++ /dev/null @@ -1,777 +0,0 @@ -{ - "step": 6, - "id": "06-recover-reviewed-2", - "title": "Expire access and restart", - "built": "Expiry, atomic grant events, recovery, and verified source archives", - "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", - "previous": "06-recover-reviewed", - "startedAt": "2026-09-07T13:46:59.784Z", - "recordedAt": "2026-09-07T13:47:04.738Z", - "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", - "sourceHashes": { - ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", - "BUILD.md": "6c1a5e260115333c402a81e1d80a94ce70537ee99a895ab92be985648f289340", - "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", - "README.md": "dfaa8382db7de8377b6839930309d3598f4acd8f327a86fc0e7eb6d30b8619ba", - "ai-task.md": "0086e868c50a385cc77155d82c7f4508f7a15909f9149d0fc6d32ed9b5068f5c", - "capture.mjs": "c3d0ba5d7692bdf9a2e8d0d0377d8734b562b30e4c9c020824402b1c73641c48", - "checkpoint-tools.mjs": "8da4408e94c72501452ffe3e38cc815b64bed23a023972437e22b2c18e79e904", - "checkpoint-tools.test.mjs": "c4e3337b80b78c0f6ed4b9a3e4e4abe7a80ede66b7f0259bc4041a3f1c2e8441", - "checkpoint.json": "dd1f60bd8ddcff51253e922abc145c6dc701ef8fb94e0eb71a86927ab1caa18a", - "consumer.mjs": "1e82661585ad0d629f903970544774a51f609406d159877254420508d7888849", - "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", - "dashboard.html": "7af8dacc8023922eb7705d8160c583b385eb4c293fa1b0b38327e92aa816cf9c", - "export-docs.mjs": "d2bc8652f5941301f7cb07a761192f02a3c7576518c1eae576d3d63e4ca69aea", - "package-lock.json": "7b7e6d588b495fd74020864325d8004ad797fb6b7fc563fc373f07c5c2501479", - "package.json": "ef897dfba5db538839afe01d6a7e1c30bd63df8ba7cea2b2b04ea326b749d84b", - "provider.mjs": "51494ac02da6a5ed2d942d6b198737a70496ecf9b7f84eae92af24f44a5a7b15", - "scenario.mjs": "d8502ba81eb09fe42d574a38cd893c3acf6cf9ad8f43f91044e967a282920860", - "server.mjs": "44fe1930e987c0aca101d0360cd3a6cb552d344c443ea62aead9cd0e607ea143", - "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", - "verify.mjs": "0f10fac7a4010b3029546fc315c67753247e83fd16e3c3ebc3108dd74b5427f7", - "webhooks.mjs": "6124cf3bdb4404c2f5a712ae8e9fc8e3382a169f8aa79d0e5476a7d5773b9800" - }, - "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 is explicitly unimplemented", - "Cancellation after expiry is ignored", - "Expired fixture evidence has an expired verdict", - "Tampered HTTP body has no inbox effect", - "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", - "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" - ], - "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": "14dbaa69-d751-450d-b019-889dea0159b8", - "deliveryId": "a7dc90a3-94aa-4655-98c7-afe9a6eb7f8e", - "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": "14dbaa69-d751-450d-b019-889dea0159b8", - "deliveryId": "a7dc90a3-94aa-4655-98c7-afe9a6eb7f8e", - "attempts": 0, - "status": "pending", - "eventType": "entitlement.granted" - }, - { - "eventId": "2256060e-f537-4039-8fc4-039353a6bf09", - "deliveryId": "d0955a28-ba7e-454d-bd93-9d5ffdd1f789", - "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": "14dbaa69-d751-450d-b019-889dea0159b8", - "deliveryId": "a7dc90a3-94aa-4655-98c7-afe9a6eb7f8e", - "attempts": 3, - "status": "delivered", - "eventType": "entitlement.granted" - }, - { - "eventId": "2256060e-f537-4039-8fc4-039353a6bf09", - "deliveryId": "d0955a28-ba7e-454d-bd93-9d5ffdd1f789", - "attempts": 3, - "status": "delivered", - "eventType": "subscription.canceled" - } - ], - "inboxCount": 2, - "responses": [ - { - "operation": "webhook: receiver unavailable", - "body": [ - { - "eventId": "14dbaa69-d751-450d-b019-889dea0159b8", - "deliveryId": "a7dc90a3-94aa-4655-98c7-afe9a6eb7f8e", - "httpStatus": 503, - "attempt": 1, - "status": "pending" - }, - { - "eventId": "2256060e-f537-4039-8fc4-039353a6bf09", - "deliveryId": "d0955a28-ba7e-454d-bd93-9d5ffdd1f789", - "httpStatus": 503, - "attempt": 1, - "status": "pending" - } - ] - }, - { - "operation": "webhook: retry after restart", - "body": [ - { - "eventId": "14dbaa69-d751-450d-b019-889dea0159b8", - "deliveryId": "a7dc90a3-94aa-4655-98c7-afe9a6eb7f8e", - "httpStatus": 200, - "attempt": 2, - "status": "delivered" - }, - { - "eventId": "2256060e-f537-4039-8fc4-039353a6bf09", - "deliveryId": "d0955a28-ba7e-454d-bd93-9d5ffdd1f789", - "httpStatus": 200, - "attempt": 2, - "status": "delivered" - } - ] - }, - { - "operation": "webhook: lost-ack redelivery", - "body": [ - { - "eventId": "14dbaa69-d751-450d-b019-889dea0159b8", - "deliveryId": "a7dc90a3-94aa-4655-98c7-afe9a6eb7f8e", - "httpStatus": 200, - "attempt": 3, - "status": "delivered" - }, - { - "eventId": "2256060e-f537-4039-8fc4-039353a6bf09", - "deliveryId": "d0955a28-ba7e-454d-bd93-9d5ffdd1f789", - "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": "14dbaa69-d751-450d-b019-889dea0159b8", - "deliveryId": "a7dc90a3-94aa-4655-98c7-afe9a6eb7f8e", - "attempts": 3, - "status": "delivered", - "eventType": "entitlement.granted" - }, - { - "eventId": "2256060e-f537-4039-8fc4-039353a6bf09", - "deliveryId": "d0955a28-ba7e-454d-bd93-9d5ffdd1f789", - "attempts": 3, - "status": "delivered", - "eventType": "subscription.canceled" - }, - { - "eventId": "b2067236-ddd3-4a31-bf9b-8956d0fd65fc", - "deliveryId": "451ac86a-d927-4837-b607-4db6a26177cf", - "attempts": 1, - "status": "delivered", - "eventType": "subscription.expired" - }, - { - "eventId": "101395a5-a0d0-4b24-b1d4-df6654754a01", - "deliveryId": "e4084c44-5df1-424b-8e71-fbef31e77286", - "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": "b2067236-ddd3-4a31-bf9b-8956d0fd65fc", - "deliveryId": "451ac86a-d927-4837-b607-4db6a26177cf", - "httpStatus": 200, - "attempt": 1, - "status": "delivered" - }, - { - "eventId": "101395a5-a0d0-4b24-b1d4-df6654754a01", - "deliveryId": "e4084c44-5df1-424b-8e71-fbef31e77286", - "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 - } - ], - "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/06-recover-reviewed-2/screen.png b/docs/build/06-recover-reviewed-2/screen.png deleted file mode 100644 index de5be8a..0000000 Binary files a/docs/build/06-recover-reviewed-2/screen.png and /dev/null differ diff --git a/docs/build/06-recover-reviewed-2/source.tar.gz b/docs/build/06-recover-reviewed-2/source.tar.gz deleted file mode 100644 index e20dcb2..0000000 Binary files a/docs/build/06-recover-reviewed-2/source.tar.gz and /dev/null differ diff --git a/docs/build/06-recover-reviewed-3/attempt-1.txt b/docs/build/06-recover-reviewed-3/attempt-1.txt deleted file mode 100644 index 5c2ae55..0000000 --- a/docs/build/06-recover-reviewed-3/attempt-1.txt +++ /dev/null @@ -1,20 +0,0 @@ -{ - "startedAt": "2026-09-07T14:11:45.641Z", - "commands": [ - { - "command": "npm ci --ignore-scripts --no-audit --no-fund", - "exitCode": 0, - "output": "\nadded 10 packages in 613ms\n" - }, - { - "command": "npm test", - "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs\n\nCommerce Lab: 130 checks passed. No store or production service contacted.\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) patches preserve path-like source text across additions, changes, and deletions [239.09ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.74ms]\n\n 2 pass\n 0 fail\nRan 2 tests across 1 file. [279.00ms]\n" - } - ] -} diff --git a/docs/build/06-recover-reviewed-3/bridge-first-attempt.txt b/docs/build/06-recover-reviewed-3/bridge-first-attempt.txt deleted file mode 100644 index 032a75c..0000000 --- a/docs/build/06-recover-reviewed-3/bridge-first-attempt.txt +++ /dev/null @@ -1,19 +0,0 @@ -Command: npm test -Exit code: 1 - -> openiap-commerce-protocol-example@0.0.0 test -> bun verify.mjs - -14 | store === "apple" -15 | ? { apple: { jws: purchaseToken } } -16 | : { google: { purchaseToken } }, -17 | }; -18 | if (!validate(operation("verifyPurchase").input, input)) -19 | throw new Error("Purchase evidence does not match the protocol input"); - ^ -error: Purchase evidence does not match the protocol input - at toVerifyPurchaseInput (/client-bridge.mjs:19:15) - at runBridgeDemo (/client-bridge.mjs:27:19) - at verifyLab (/verify.mjs:329:18) - -Bun v1.3.13 (macOS arm64) diff --git a/docs/build/06-recover-reviewed-3/changes.patch b/docs/build/06-recover-reviewed-3/changes.patch deleted file mode 100644 index d8b682f..0000000 --- a/docs/build/06-recover-reviewed-3/changes.patch +++ /dev/null @@ -1,293 +0,0 @@ -diff --git a/INTEGRATE.md b/INTEGRATE.md -new file mode 100644 -index 0000000..754c1c7 ---- /dev/null -+++ b/INTEGRATE.md -@@ -0,0 +1,92 @@ -+# Build your part of the OpenIAP ecosystem -+ -+Use this brief with an AI in your product repository. Pick the role you sell; -+connect the other roles to existing services. An integrated platform can own -+several roles. These are product roles, not additional protocol profiles. -+ -+| Your product | You deliver | Connect to | -+| --------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -+| Paywalls and experiments | Presentation, product selection, purchase/result callbacks | The app's existing OpenIAP purchase flow; optionally lifecycle events | -+| Commerce backend | Store verification, ownership, access, lifecycle delivery for the profiles you declare | Authenticated app backend, store adapters, downstream receivers | -+| Analytics, attribution, CRM | Durable event ingestion and your own reporting or automation | A configured commerce emitter | -+| Integrated platform | The roles above that your product supplies | One app integration with an explicit owner for each state transition | -+ -+## Start with working code -+ -+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 -+ 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. -+- `npm test` and `npm start`: verify and inspect the fixture commerce backend. -+ -+The bridge and consumer can be used separately. These checks prove local -+boundaries with fictional inputs, not a mobile checkout or a store adapter. -+The [README](README.md) gives the receiver endpoint, configuration, and limits. -+ -+## Task for the AI -+ -+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 -+vectors for the role being implemented. Package 0.1.0 does not ship `DESIGN.md`; -+the [online implementation guide](https://openiap.dev/commerce-protocol/ecosystem) -+and [whitepaper](https://openiap.dev/commerce-protocol/whitepaper) give context. -+ -+Implement the selected role using the product's existing framework and design -+system. Deliver usable code and a short connection example, not a list of work -+for the app team. Follow these boundaries: -+ -+1. **Paywall:** accept the app's store-fetched products, return a selected product -+ ID to its existing purchase callback, and display pending, canceled, failed, -+ and fulfilled results. Let the host select valid store offers. Never infer -+ purchase success or access from a click. Wire result callbacks to the host's -+ existing purchase lifecycle. Commerce Protocol defines no universal paywall -+ 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 -+ 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 -+ the transaction through the client library. Keep store acknowledgement and -+ consumption responsibilities explicit for the chosen store and product type. -+3. **Commerce:** provide core discovery and implement every operation/obligation -+ of each advertised profile and binding. Account lifecycle includes erasure. -+ Keep one authoritative ownership and entitlement service for each app/project, -+ even when it delegates verification. Use [BUILD.md](BUILD.md) for the backend -+ implementation sequence. The fixture backend advertises no complete profiles. -+4. **Data:** reuse or port `webhooks.mjs` and `consumer.mjs`. Authenticate exact -+ body bytes before parsing, validate, durably deduplicate in the configured -+ emitter/project scope, then acknowledge. Keep optional unknown values unknown. -+ Receiving events does not qualify a product for the `events` emitter profile. -+ Lifecycle events alone are not a revenue ledger: charge, refund, trial, tax, -+ currency conversion, attribution, and reporting policies need their own data -+ and product-specific rules. Do not count every event as a new purchase. -+ -+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. -+ -+## Deliver and prove the connection -+ -+Ship the adapter/endpoint, setup command, supported stores and profiles, -+credential configuration, and one app-facing integration example. Agree on -+opaque user IDs, issuer/project scope, and versions with connected services. -+Protocol compatibility does not perform onboarding, provisioning, ownership -+migration, or provider discovery for the app automatically. -+ -+For each owned role, run a successful flow and its failure cases. Inspect the -+rendered result and actual response, fix the failing behavior, and repeat the -+same check. Save the source revision, commands, responses, screenshots, and -+limits. Include cancellation, pending purchase, duplicate delivery, expiry, -+and account isolation where applicable. An integrated platform must prove each -+role it claims; a paywall specialist need not implement a purchase verifier. -+ -+Label fixture checks separately from real device/store sandbox checks and full -+profile conformance. Never claim the latter from a schema match alone. -diff --git a/README.md b/README.md -index c4cfc8b..e3acddd 100644 ---- a/README.md -+++ b/README.md -@@ -18,6 +18,14 @@ This example uses the Bun runtime for HTTP and SQLite. Its scripts work through - any package manager with Bun installed. The protocol can be implemented in your - own language and stack. Open http://127.0.0.1:5181 and run each available step. - -+## Connect your product -+ -+Start with [INTEGRATE.md](INTEGRATE.md) to build a paywall integration, a commerce -+service, an event consumer, or an integrated platform. Run `npm run demo:bridge` -+to check Apple/Google OpenIAP purchase fields against the installed verification -+input schema. The helper runs on the app backend; it does not perform a mobile -+purchase or authenticate evidence. -+ - ## Receive events in an existing backend - - The ready receiver verifies signatures and saves each event once in SQLite. -diff --git a/ai-task.md b/ai-task.md -index 0bea667..cc08529 100644 ---- a/ai-task.md -+++ b/ai-task.md -@@ -20,3 +20,14 @@ 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. -diff --git a/checkpoint-tools.mjs b/checkpoint-tools.mjs -index 2e45db8..5c8d254 100644 ---- a/checkpoint-tools.mjs -+++ b/checkpoint-tools.mjs -@@ -18,6 +18,7 @@ export const SOURCE_FILES = [ - "LICENSE", - "README.md", - "BUILD.md", -+ "INTEGRATE.md", - "ai-task.md", - "checkpoint.json", - "package.json", -@@ -26,6 +27,7 @@ export const SOURCE_FILES = [ - "provider.mjs", - "webhooks.mjs", - "consumer.mjs", -+ "client-bridge.mjs", - "scenario.mjs", - "server.mjs", - "verify.mjs", -diff --git a/checkpoint.json b/checkpoint.json -index d416197..4c6e9aa 100644 ---- a/checkpoint.json -+++ b/checkpoint.json -@@ -1,8 +1,8 @@ - { - "step": 6, -- "id": "06-recover-reviewed-2", -+ "id": "06-recover-reviewed-3", - "title": "Expire access and restart", - "built": "Expiry, atomic grant events, recovery, and verified source archives", - "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", -- "previous": "06-recover-reviewed" -+ "previous": "06-recover-reviewed-2" - } -diff --git a/client-bridge.mjs b/client-bridge.mjs -new file mode 100644 -index 0000000..1d5d64a ---- /dev/null -+++ b/client-bridge.mjs -@@ -0,0 +1,70 @@ -+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) { -+ 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 } }), -+ }; -+ if (!validate(operation("verifyPurchase").input, input)) -+ throw new Error("Purchase evidence does not match the protocol input"); -+ return input; -+} -+ -+export function runBridgeDemo() { -+ const checks = []; -+ for (const store of ["apple", "google"]) { -+ const token = `fictional-${store}-evidence`; -+ const input = toVerifyPurchaseInput({ -+ store, -+ purchaseToken: token, -+ productId: "premium.monthly", -+ userId: "untrusted-client-claim", -+ }); -+ assert.deepEqual(input, { -+ store, -+ ...(store === "apple" -+ ? { apple: { jws: token } } -+ : { google: { purchaseToken: token } }), -+ }); -+ checks.push(`${store}: maps evidence without forwarding client identity`); -+ 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", -+ { store: "amazon", purchaseToken: "fictional" }, -+ ], -+ [ -+ "Horizon needs its own adapter", -+ { store: "horizon", purchaseToken: "fictional" }, -+ ], -+ ["missing evidence", { store: "apple" }], -+ ["blank evidence", { store: "google", purchaseToken: " " }], -+ ["non-string evidence", { store: "apple", purchaseToken: 123 }], -+ [ -+ "oversized evidence", -+ { store: "apple", purchaseToken: "x".repeat(16385) }, -+ ], -+ ]) { -+ assert.throws(() => toVerifyPurchaseInput(purchase), Error); -+ checks.push(`Rejects ${label}`); -+ } -+ return checks; -+} -+ -+if (import.meta.main) -+ console.log( -+ `Client boundary: ${runBridgeDemo().length} checks passed against the installed contract. Fixture fields only; no SDK checkout or store contacted.`, -+ ); -diff --git a/export-docs.mjs b/export-docs.mjs -index 731e40d..ab8208f 100644 ---- a/export-docs.mjs -+++ b/export-docs.mjs -@@ -103,6 +103,7 @@ try { - extract(join(source, last.id, "source.tar.gz"), temp); - assert.deepEqual(hashes(temp), last.sourceHashes); - cpSync(join(temp, "BUILD.md"), join(output, "build-brief.md")); -+ cpSync(join(temp, "INTEGRATE.md"), join(output, "integration-brief.md")); - } finally { - rmSync(temp, { recursive: true, force: true }); - } -diff --git a/package.json b/package.json -index 4ac31f6..b2c0932 100644 ---- a/package.json -+++ b/package.json -@@ -10,7 +10,8 @@ - "verify:checkpoints": "bun verify-checkpoints.mjs", - "test:tooling": "bun test checkpoint-tools.test.mjs", - "consumer": "bun consumer.mjs", -- "demo:consumer": "bun consumer.mjs demo" -+ "demo:consumer": "bun consumer.mjs demo", -+ "demo:bridge": "bun client-bridge.mjs" - }, - "dependencies": { - "openiap-commerce-protocol": "0.1.0", -diff --git a/verify.mjs b/verify.mjs -index bc77b7a..28ad2cf 100644 ---- a/verify.mjs -+++ b/verify.mjs -@@ -5,6 +5,7 @@ import { SUBSCRIPTION_STATES, WEBHOOK } from "openiap-commerce-protocol"; - import { FIXTURE, isEntitled } from "./provider.mjs"; - import { requestOperation } from "./scenario.mjs"; - import { runConsumerDemo } from "./consumer.mjs"; -+import { runBridgeDemo } from "./client-bridge.mjs"; - import { startLab } from "./server.mjs"; - import { authentic, deliver, sign } from "./webhooks.mjs"; - -@@ -325,6 +326,7 @@ export async function verifyLab({ compareSigner } = {}) { - rmSync(delayed.directory, { recursive: true, force: true }); - } - checks.push(...(await runConsumerDemo()).checks); -+ checks.push(...runBridgeDemo()); - return checks; - } - diff --git a/docs/build/06-recover-reviewed-3/mobile.png b/docs/build/06-recover-reviewed-3/mobile.png deleted file mode 100644 index 7e835b9..0000000 Binary files a/docs/build/06-recover-reviewed-3/mobile.png and /dev/null differ diff --git a/docs/build/06-recover-reviewed-3/run.json b/docs/build/06-recover-reviewed-3/run.json deleted file mode 100644 index 845b7b4..0000000 --- a/docs/build/06-recover-reviewed-3/run.json +++ /dev/null @@ -1,791 +0,0 @@ -{ - "step": 6, - "id": "06-recover-reviewed-3", - "title": "Expire access and restart", - "built": "Expiry, atomic grant events, recovery, and verified source archives", - "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", - "previous": "06-recover-reviewed-2", - "startedAt": "2026-09-07T14:11:45.641Z", - "recordedAt": "2026-09-07T14:11:49.816Z", - "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", - "sourceHashes": { - ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", - "BUILD.md": "6c1a5e260115333c402a81e1d80a94ce70537ee99a895ab92be985648f289340", - "INTEGRATE.md": "0000e734b2d9cce4cfea7527effe58ad789be1b36a8f65e918b8c367d57fd825", - "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", - "README.md": "a8881e84a9d1faeb4fe95c18a2a7f2713a58005bb7e6991d91d70c645c5d2232", - "ai-task.md": "0225613521c4e61a9ca4fd195d7d762159c235364bb28af19fce4d9315bac224", - "capture.mjs": "c3d0ba5d7692bdf9a2e8d0d0377d8734b562b30e4c9c020824402b1c73641c48", - "checkpoint-tools.mjs": "3ec460d856336f106bc8b63e67f49e42f58bd280ea700f2a97e5696b17007a17", - "checkpoint-tools.test.mjs": "c4e3337b80b78c0f6ed4b9a3e4e4abe7a80ede66b7f0259bc4041a3f1c2e8441", - "checkpoint.json": "d8641e426b0ab70d1cbf1e7340f42aa167c9fea8d02a531686b9ec9de0453ab2", - "client-bridge.mjs": "a0058579b02c2f3a770c7ff7b59dfe41ce50761361d9a0c7a2df96ab1b6b4acf", - "consumer.mjs": "1e82661585ad0d629f903970544774a51f609406d159877254420508d7888849", - "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", - "dashboard.html": "7af8dacc8023922eb7705d8160c583b385eb4c293fa1b0b38327e92aa816cf9c", - "export-docs.mjs": "6c25ec23b21cc0ba0caaaad35a031235eb09ced97f85a5b1ae32b5371050d7ac", - "package-lock.json": "7b7e6d588b495fd74020864325d8004ad797fb6b7fc563fc373f07c5c2501479", - "package.json": "a3ccb3acde48e0d463010724ddef9a40437ce45ccfbf997ba3942ae58b5609e3", - "provider.mjs": "51494ac02da6a5ed2d942d6b198737a70496ecf9b7f84eae92af24f44a5a7b15", - "scenario.mjs": "d8502ba81eb09fe42d574a38cd893c3acf6cf9ad8f43f91044e967a282920860", - "server.mjs": "44fe1930e987c0aca101d0360cd3a6cb552d344c443ea62aead9cd0e607ea143", - "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", - "verify.mjs": "5ead979b272f7d2f01b30c50641377d496e5632ae72ca2582be9e13bdcf73ab6", - "webhooks.mjs": "6124cf3bdb4404c2f5a712ae8e9fc8e3382a169f8aa79d0e5476a7d5773b9800" - }, - "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 is explicitly unimplemented", - "Cancellation after expiry is ignored", - "Expired fixture evidence has an expired verdict", - "Tampered HTTP body has no inbox effect", - "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", - "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" - ], - "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": "af146750-2b91-48fb-a0fc-3ea9b8fb88b6", - "deliveryId": "9c2e9a07-32ba-496c-a35f-c0bf27d7ffb7", - "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": "af146750-2b91-48fb-a0fc-3ea9b8fb88b6", - "deliveryId": "9c2e9a07-32ba-496c-a35f-c0bf27d7ffb7", - "attempts": 0, - "status": "pending", - "eventType": "entitlement.granted" - }, - { - "eventId": "fbe8344b-9365-4e43-b0bc-ed85f2729e3d", - "deliveryId": "a97c75f8-f96e-4551-9ebe-4b5a496f6d92", - "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": "af146750-2b91-48fb-a0fc-3ea9b8fb88b6", - "deliveryId": "9c2e9a07-32ba-496c-a35f-c0bf27d7ffb7", - "attempts": 3, - "status": "delivered", - "eventType": "entitlement.granted" - }, - { - "eventId": "fbe8344b-9365-4e43-b0bc-ed85f2729e3d", - "deliveryId": "a97c75f8-f96e-4551-9ebe-4b5a496f6d92", - "attempts": 3, - "status": "delivered", - "eventType": "subscription.canceled" - } - ], - "inboxCount": 2, - "responses": [ - { - "operation": "webhook: receiver unavailable", - "body": [ - { - "eventId": "af146750-2b91-48fb-a0fc-3ea9b8fb88b6", - "deliveryId": "9c2e9a07-32ba-496c-a35f-c0bf27d7ffb7", - "httpStatus": 503, - "attempt": 1, - "status": "pending" - }, - { - "eventId": "fbe8344b-9365-4e43-b0bc-ed85f2729e3d", - "deliveryId": "a97c75f8-f96e-4551-9ebe-4b5a496f6d92", - "httpStatus": 503, - "attempt": 1, - "status": "pending" - } - ] - }, - { - "operation": "webhook: retry after restart", - "body": [ - { - "eventId": "af146750-2b91-48fb-a0fc-3ea9b8fb88b6", - "deliveryId": "9c2e9a07-32ba-496c-a35f-c0bf27d7ffb7", - "httpStatus": 200, - "attempt": 2, - "status": "delivered" - }, - { - "eventId": "fbe8344b-9365-4e43-b0bc-ed85f2729e3d", - "deliveryId": "a97c75f8-f96e-4551-9ebe-4b5a496f6d92", - "httpStatus": 200, - "attempt": 2, - "status": "delivered" - } - ] - }, - { - "operation": "webhook: lost-ack redelivery", - "body": [ - { - "eventId": "af146750-2b91-48fb-a0fc-3ea9b8fb88b6", - "deliveryId": "9c2e9a07-32ba-496c-a35f-c0bf27d7ffb7", - "httpStatus": 200, - "attempt": 3, - "status": "delivered" - }, - { - "eventId": "fbe8344b-9365-4e43-b0bc-ed85f2729e3d", - "deliveryId": "a97c75f8-f96e-4551-9ebe-4b5a496f6d92", - "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": "af146750-2b91-48fb-a0fc-3ea9b8fb88b6", - "deliveryId": "9c2e9a07-32ba-496c-a35f-c0bf27d7ffb7", - "attempts": 3, - "status": "delivered", - "eventType": "entitlement.granted" - }, - { - "eventId": "fbe8344b-9365-4e43-b0bc-ed85f2729e3d", - "deliveryId": "a97c75f8-f96e-4551-9ebe-4b5a496f6d92", - "attempts": 3, - "status": "delivered", - "eventType": "subscription.canceled" - }, - { - "eventId": "d931d1ae-c8c7-4894-a3cb-2daa0ca55810", - "deliveryId": "3088df18-467b-46f6-a1dc-f61372ba9fad", - "attempts": 1, - "status": "delivered", - "eventType": "subscription.expired" - }, - { - "eventId": "32dcbac4-4ff0-4947-ba95-2dbe3117727d", - "deliveryId": "ae561dcb-4c46-42ca-82ae-6c07a584fc9f", - "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": "d931d1ae-c8c7-4894-a3cb-2daa0ca55810", - "deliveryId": "3088df18-467b-46f6-a1dc-f61372ba9fad", - "httpStatus": 200, - "attempt": 1, - "status": "delivered" - }, - { - "eventId": "32dcbac4-4ff0-4947-ba95-2dbe3117727d", - "deliveryId": "ae561dcb-4c46-42ca-82ae-6c07a584fc9f", - "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 - } - ], - "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/06-recover-reviewed-3/screen.png b/docs/build/06-recover-reviewed-3/screen.png deleted file mode 100644 index de5be8a..0000000 Binary files a/docs/build/06-recover-reviewed-3/screen.png and /dev/null differ diff --git a/docs/build/06-recover-reviewed-3/source.tar.gz b/docs/build/06-recover-reviewed-3/source.tar.gz deleted file mode 100644 index 13f2d2e..0000000 Binary files a/docs/build/06-recover-reviewed-3/source.tar.gz and /dev/null differ diff --git a/docs/build/06-recover-reviewed-4/attempt-1.txt b/docs/build/06-recover-reviewed-4/attempt-1.txt deleted file mode 100644 index 52c9030..0000000 --- a/docs/build/06-recover-reviewed-4/attempt-1.txt +++ /dev/null @@ -1,20 +0,0 @@ -{ - "startedAt": "2026-09-07T14:33:50.115Z", - "commands": [ - { - "command": "npm ci --ignore-scripts --no-audit --no-fund", - "exitCode": 0, - "output": "\nadded 10 packages in 352ms\n" - }, - { - "command": "npm test", - "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs\n\nCommerce Protocol Example: 133 checks passed. No store or production service contacted.\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) patches preserve path-like source text across additions, changes, and deletions [42.52ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.53ms]\n(pass) unfinished captures do not hide completed records [0.71ms]\n\n 3 pass\n 0 fail\nRan 3 tests across 1 file. [61.00ms]\n" - } - ] -} diff --git a/docs/build/06-recover-reviewed-4/changes.patch b/docs/build/06-recover-reviewed-4/changes.patch deleted file mode 100644 index 59ab0b1..0000000 --- a/docs/build/06-recover-reviewed-4/changes.patch +++ /dev/null @@ -1,370 +0,0 @@ -diff --git a/.yarnrc.yml b/.yarnrc.yml -new file mode 100644 -index 0000000..3186f3f ---- /dev/null -+++ b/.yarnrc.yml -@@ -0,0 +1 @@ -+nodeLinker: node-modules -diff --git a/INTEGRATE.md b/INTEGRATE.md -index 754c1c7..49b04aa 100644 ---- a/INTEGRATE.md -+++ b/INTEGRATE.md -@@ -13,6 +13,12 @@ several roles. These are product roles, not additional protocol profiles. - - ## Start with working code - -+[Download the complete example](https://openiap.dev/commerce-example/06-recover-reviewed-4/source.tar.gz) -+and extract it into an empty directory. It contains `client-bridge.mjs`, -+`consumer.mjs`, `webhooks.mjs`, the backend, and their executable checks. -+The [example repository](https://github.com/hyodotdev/openiap-commerce-protocol-example) -+contains the same project and its build history. -+ - 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. -@@ -25,7 +31,8 @@ or `bun install`. This example's runtime is Bun. The contract is - - The bridge and consumer can be used separately. These checks prove local - boundaries with fictional inputs, not a mobile checkout or a store adapter. --The [README](README.md) gives the receiver endpoint, configuration, and limits. -+The [receiver setup guide](https://openiap.dev/commerce-protocol/getting-started#receive-events) -+gives the endpoint, configuration, and limits. - - ## Task for the AI - -@@ -33,7 +40,7 @@ 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 - vectors for the role being implemented. Package 0.1.0 does not ship `DESIGN.md`; --the [online implementation guide](https://openiap.dev/commerce-protocol/ecosystem) -+the [role guide](https://openiap.dev/commerce-protocol/ecosystem) - and [whitepaper](https://openiap.dev/commerce-protocol/whitepaper) give context. - - Implement the selected role using the product's existing framework and design -@@ -57,7 +64,7 @@ for the app team. Follow these boundaries: - 3. **Commerce:** provide core discovery and implement every operation/obligation - of each advertised profile and binding. Account lifecycle includes erasure. - Keep one authoritative ownership and entitlement service for each app/project, -- even when it delegates verification. Use [BUILD.md](BUILD.md) for the backend -+ even when it delegates verification. Use [backend build brief](https://openiap.dev/commerce-example/build-brief.md) for the backend - implementation sequence. The fixture backend advertises no complete profiles. - 4. **Data:** reuse or port `webhooks.mjs` and `consumer.mjs`. Authenticate exact - body bytes before parsing, validate, durably deduplicate in the configured -diff --git a/README.md b/README.md -index e3acddd..2834591 100644 ---- a/README.md -+++ b/README.md -@@ -15,7 +15,8 @@ npm start - ``` - - This example uses the Bun runtime for HTTP and SQLite. Its scripts work through --any package manager with Bun installed. The protocol can be implemented in your -+any package manager with Bun installed. The included `.yarnrc.yml` selects -+`node_modules` for modern Yarn so Bun can resolve the installed packages. The protocol can be implemented in your - own language and stack. Open http://127.0.0.1:5181 and run each available step. - - ## Connect your product -@@ -44,7 +45,8 @@ To run the receiver continuously, set `COMMERCE_WEBHOOK_SECRET` to your - provider's signing secret and run `npm run consumer`. It listens at - `http://127.0.0.1:5182/webhooks/commerce`, with storage in `consumer.sqlite` - (or `COMMERCE_INBOX_PATH`). It is bound to loopback: an HTTPS reverse proxy --must forward to that local address, preserving the exact body bytes. Configure -+must forward to that local address, preserving the exact body bytes. Forwarding the public Host header is supported; -+the signature authenticates delivery. Configure - one emitter/project and signing key per receiver database. Keep the inbox file - on persistent storage. `webhooks.mjs` exports the same Fetch-compatible receiver - handler for embedding in an existing server. -@@ -96,3 +98,5 @@ preceding archive, so patch generation also works after a fresh clone. Run - empty directory and save the npm execution evidence. Published source - checkpoints are immutable; - use a new directory name in `checkpoint.json` for another revision. -+Verification/export skip unfinished captures without `run.json`; their failure -+logs remain on disk until that checkpoint succeeds. -diff --git a/ai-task.md b/ai-task.md -index cc08529..9eeb0e6 100644 ---- a/ai-task.md -+++ b/ai-task.md -@@ -31,3 +31,10 @@ 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. -diff --git a/capture.mjs b/capture.mjs -index 75621cc..564f82f 100644 ---- a/capture.mjs -+++ b/capture.mjs -@@ -160,7 +160,7 @@ try { - } catch (error) { - writeFileSync( - join(output, `failure-${attempt}.txt`), -- `${new Date().toISOString()}\n${sanitize(error.stack)}\n`, -+ `${new Date().toISOString()}\n${sanitize(String(error?.stack ?? error))}\n`, - ); - throw error; - } finally { -diff --git a/checkpoint-tools.mjs b/checkpoint-tools.mjs -index 5c8d254..a87d59a 100644 ---- a/checkpoint-tools.mjs -+++ b/checkpoint-tools.mjs -@@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; - import { spawnSync } from "node:child_process"; - import { - cpSync, -+ existsSync, - mkdirSync, - mkdtempSync, - readFileSync, -@@ -15,6 +16,7 @@ import { join } from "node:path"; - - export const SOURCE_FILES = [ - ".gitignore", -+ ".yarnrc.yml", - "LICENSE", - "README.md", - "BUILD.md", -@@ -53,7 +55,7 @@ export function sanitize(text) { - let result = text; - for (const root of roots) result = result.replaceAll(root, ""); - return result.replace( -- /\/[^\s"']*\/commerce-(?:capture|patch|verify|lab|compare|consumer)-[^\s/"']+/g, -+ /\/[^\s"']*\/commerce-(?:capture|patch|verify|lab|example|compare|consumer)-[^\s/"']+/g, - "", - ); - } -@@ -124,7 +126,11 @@ export function createPatch(before, after) { - } - export function readRecords(directory) { - return readdirSync(directory) -- .filter((name) => /^\d\d-[\w-]+$/.test(name)) -+ .filter( -+ (name) => -+ /^\d\d-[\w-]+$/.test(name) && -+ existsSync(join(directory, name, "run.json")), -+ ) - .sort() - .map((name) => - JSON.parse(readFileSync(join(directory, name, "run.json"), "utf8")), -diff --git a/checkpoint-tools.test.mjs b/checkpoint-tools.test.mjs -index bc1c224..e050dc9 100644 ---- a/checkpoint-tools.test.mjs -+++ b/checkpoint-tools.test.mjs -@@ -3,7 +3,12 @@ import { test } from "bun:test"; - import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; - import { tmpdir, homedir } from "node:os"; - import { join } from "node:path"; --import { createPatch, nextAttempt, sanitize } from "./checkpoint-tools.mjs"; -+import { -+ createPatch, -+ nextAttempt, -+ sanitize, -+ readRecords, -+} from "./checkpoint-tools.mjs"; - - test("patches preserve path-like source text across additions, changes, and deletions", () => { - const temp = mkdtempSync(join(tmpdir(), "commerce-patch-test-")); -@@ -45,3 +50,24 @@ test("failed attempts get new numbers and private paths are redacted", () => { - rmSync(temp, { recursive: true, force: true }); - } - }); -+ -+test("unfinished captures do not hide completed records", () => { -+ const temp = mkdtempSync(join(tmpdir(), "commerce-capture-test-")); -+ try { -+ mkdirSync(join(temp, "01-complete")); -+ writeFileSync( -+ join(temp, "01-complete/run.json"), -+ JSON.stringify({ id: "01-complete" }), -+ ); -+ mkdirSync(join(temp, "02-failed")); -+ writeFileSync(join(temp, "02-failed/failure-1.txt"), "Browser unavailable"); -+ assert.deepEqual(readRecords(temp), [{ id: "01-complete" }]); -+ writeFileSync( -+ join(temp, "02-failed/run.json"), -+ JSON.stringify({ id: "02-failed" }), -+ ); -+ assert.equal(readRecords(temp).length, 2); -+ } finally { -+ rmSync(temp, { recursive: true, force: true }); -+ } -+}); -diff --git a/checkpoint.json b/checkpoint.json -index 4c6e9aa..660312c 100644 ---- a/checkpoint.json -+++ b/checkpoint.json -@@ -1,8 +1,8 @@ - { - "step": 6, -- "id": "06-recover-reviewed-3", -+ "id": "06-recover-reviewed-4", - "title": "Expire access and restart", - "built": "Expiry, atomic grant events, recovery, and verified source archives", - "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", -- "previous": "06-recover-reviewed-2" -+ "previous": "06-recover-reviewed-3" - } -diff --git a/consumer.mjs b/consumer.mjs -index e4373eb..972ef32 100644 ---- a/consumer.mjs -+++ b/consumer.mjs -@@ -17,10 +17,6 @@ export function startConsumer({ secret, path, port = 0, now = Date.now }) { - maxRequestBodySize: 64 * 1024, - fetch(request) { - const url = new URL(request.url); -- const origin = request.headers.get("origin"); -- const expected = `http://127.0.0.1:${server.port}`; -- if (url.origin !== expected || (origin && origin !== expected)) -- return new Response("Local requests only", { status: 403 }); - if (request.method === "POST" && url.pathname === "/webhooks/commerce") - return receiver.fetch(request); - if (request.method === "GET" && url.pathname === "/health") -@@ -67,6 +63,10 @@ export async function runConsumerDemo() { - ["subscription.refunded", "Refunded", false], - ]; - try { -+ const health = await fetch(new URL("/health", consumer.url), { -+ headers: { host: "receiver.example.test" }, -+ }); -+ check("Health accepts the proxy public Host header", health.status, 200); - for (const [index, [eventType, state, active]] of lifecycle.entries()) { - const event = { - eventId: randomUUID(), -@@ -100,6 +100,7 @@ export async function runConsumerDemo() { - const body = JSON.stringify(event); - const timestamp = String(Math.floor(now / 1000)); - const headers = { -+ host: "receiver.example.test", - "content-type": WEBHOOK.contentType, - [WEBHOOK.timestampHeader]: timestamp, - [WEBHOOK.signatureHeader]: sign(secret, timestamp, body), -@@ -122,10 +123,11 @@ export async function runConsumerDemo() { - headers, - body, - }); -+ const duplicateResult = await duplicate.json(); - check( - `${eventType}: redelivery deduplicated`, -- (await duplicate.json()).duplicate, -- true, -+ [duplicate.status, duplicateResult.duplicate], -+ [200, true], - ); - const tampered = await fetch(consumer.url, { - method: "POST", -@@ -136,7 +138,8 @@ export async function runConsumerDemo() { - results.push({ - event, - httpStatus: response.status, -- duplicate: true, -+ duplicate: duplicateResult.duplicate, -+ requestHost: headers.host, - tamperedHttpStatus: tampered.status, - }); - } -diff --git a/provider.mjs b/provider.mjs -index c134436..643c03c 100644 ---- a/provider.mjs -+++ b/provider.mjs -@@ -127,7 +127,7 @@ export function createProvider(path, now) { - processedAt: now(), - store: FIXTURE.store, - environment: "local-fixture", -- projectId: "commerce_lab", -+ projectId: "commerce_example", - productId: row.product_id, - ...(row.user_id ? { userId: row.user_id } : {}), - subscription: snapshot(row), -@@ -257,7 +257,7 @@ export function createProvider(path, now) { - throw new Error("Premature expiry requires store reconciliation"); - } - db.query("INSERT INTO observations VALUES (?)").run(id); -- if (occurredAt <= row.observed_at) return false; -+ if (occurredAt < row.observed_at) return false; - if ( - (kind === "cancel" && (!row.will_renew || row.state !== "Active")) || - (kind === "expire" && row.state === "Expired") -diff --git a/server.mjs b/server.mjs -index ad3e921..044c4c4 100644 ---- a/server.mjs -+++ b/server.mjs -@@ -7,7 +7,7 @@ import { createReceiver } from "./webhooks.mjs"; - import { createScenario, STAGES } from "./scenario.mjs"; - - export function startLab({ port = 0 } = {}) { -- const directory = mkdtempSync(join(tmpdir(), "commerce-lab-")); -+ const directory = mkdtempSync(join(tmpdir(), "commerce-example-")); - const runtime = { - time: FIXTURE.startsAt, - secret: randomBytes(32).toString("hex"), -@@ -85,7 +85,7 @@ export function startLab({ port = 0 } = {}) { - if (import.meta.main) { - const lab = startLab({ port: Number(process.env.COMMERCE_LAB_PORT ?? 5181) }); - console.log( -- `Commerce Lab: ${lab.runtime.baseUrl}\nDisposable SQLite files: ${lab.directory}\nFictional fixture store. No payments, credentials, or external services required.`, -+ `Commerce Protocol Example: ${lab.runtime.baseUrl}\nDisposable SQLite files: ${lab.directory}\nFictional fixture store. No payments, credentials, or external services required.`, - ); - for (const signal of ["SIGINT", "SIGTERM"]) - process.on(signal, async () => { -diff --git a/verify.mjs b/verify.mjs -index 28ad2cf..e43ff66 100644 ---- a/verify.mjs -+++ b/verify.mjs -@@ -325,6 +325,45 @@ export async function verifyLab({ compareSigner } = {}) { - await delayed.close(); - rmSync(delayed.directory, { recursive: true, force: true }); - } -+ const equalTime = startLab(); -+ try { -+ const call = (name, input) => -+ requestOperation(equalTime.runtime.baseUrl, name, input); -+ const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence }; -+ await call("verifyPurchase", evidence); -+ await call("bindPurchase", { ...evidence, userId: FIXTURE.userId }); -+ equalTime.runtime.time = FIXTURE.expiresAt; -+ equalTime.runtime.provider.observe({ -+ id: "cancel-at-deadline", -+ kind: "cancel", -+ occurredAt: FIXTURE.expiresAt, -+ }); -+ equalTime.runtime.provider.observe({ -+ id: "expire-at-deadline", -+ kind: "expire", -+ occurredAt: FIXTURE.expiresAt, -+ }); -+ check( -+ "Equal-time expiry retains its lifecycle transition", -+ equalTime.runtime.provider.inspect().purchases[0].state, -+ "Expired", -+ ); -+ check( -+ "Equal-time observations revoke once and preserve both transitions", -+ equalTime.runtime.provider -+ .inspect() -+ .deliveries.map((row) => row.eventType), -+ [ -+ "entitlement.granted", -+ "subscription.canceled", -+ "entitlement.revoked", -+ "subscription.expired", -+ ], -+ ); -+ } finally { -+ await equalTime.close(); -+ rmSync(equalTime.directory, { recursive: true, force: true }); -+ } - checks.push(...(await runConsumerDemo()).checks); - checks.push(...runBridgeDemo()); - return checks; -@@ -333,6 +372,6 @@ export async function verifyLab({ compareSigner } = {}) { - if (import.meta.main) { - const checks = await verifyLab(); - console.log( -- `Commerce Lab: ${checks.length} checks passed. No store or production service contacted.`, -+ `Commerce Protocol Example: ${checks.length} checks passed. No store or production service contacted.`, - ); - } diff --git a/docs/build/06-recover-reviewed-4/mobile.png b/docs/build/06-recover-reviewed-4/mobile.png deleted file mode 100644 index 7e835b9..0000000 Binary files a/docs/build/06-recover-reviewed-4/mobile.png and /dev/null differ diff --git a/docs/build/06-recover-reviewed-4/proxy-first-attempt.txt b/docs/build/06-recover-reviewed-4/proxy-first-attempt.txt deleted file mode 100644 index 43a239c..0000000 --- a/docs/build/06-recover-reviewed-4/proxy-first-attempt.txt +++ /dev/null @@ -1,28 +0,0 @@ -Command: npm run demo:consumer -Exit code: 1 - -> openiap-commerce-protocol-example@0.0.0 demo:consumer -> bun consumer.mjs demo - -52 | now: () => now, -53 | }); -54 | const checks = [], -55 | results = []; -56 | const check = (name, actual, expected) => { -57 | assert.deepEqual(actual, expected, name); - ^ -AssertionError: Health accepts the proxy public Host header - -403 !== 200 - - generatedMessage: false, - actual: 403, - expected: 200, - operator: "deepStrictEqual", - code: "ERR_ASSERTION" - - at check (/consumer.mjs:57:12) - at runConsumerDemo (/consumer.mjs:84:17) - at processTicksAndRejections (native:7:39) - -Bun v1.3.13 (macOS arm64) diff --git a/docs/build/06-recover-reviewed-4/run.json b/docs/build/06-recover-reviewed-4/run.json deleted file mode 100644 index bfdbd23..0000000 --- a/docs/build/06-recover-reviewed-4/run.json +++ /dev/null @@ -1,795 +0,0 @@ -{ - "step": 6, - "id": "06-recover-reviewed-4", - "title": "Expire access and restart", - "built": "Expiry, atomic grant events, recovery, and verified source archives", - "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", - "previous": "06-recover-reviewed-3", - "startedAt": "2026-09-07T14:33:50.115Z", - "recordedAt": "2026-09-07T14:33:53.899Z", - "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", - "sourceHashes": { - ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", - ".yarnrc.yml": "473e6def86fc03638120e0c01d0c8bbab095677256460fa4ea763e5d4697f270", - "BUILD.md": "6c1a5e260115333c402a81e1d80a94ce70537ee99a895ab92be985648f289340", - "INTEGRATE.md": "e5b7e722400e3275d9870c3cfb3ece7c647f1e661f51019ba0bb65b0505eaebe", - "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", - "README.md": "b9fc27272571f04a4de5bfbe6297bd9f1353256e64d95a86f6b0edd5b40e7576", - "ai-task.md": "c777daecc920bb004f633d93da54e0a2838ea15d40b92300bc4c09a12c4e3db7", - "capture.mjs": "ba0c11683dee687ead861d274540b0aa13a2fa8e3210be0237aabb8013ffba0b", - "checkpoint-tools.mjs": "b2cd02f33831ea09e10ed03811bfd6e687abd97255e0c2171e37d6853d5fe718", - "checkpoint-tools.test.mjs": "589fa79923392f6a1635617989501ecc46ff34d382d5af83f240e28ba66b3c61", - "checkpoint.json": "a957a4d18b562c9a506a136ce9187ca7b33fb110f049243ce3139aaff6cf99ed", - "client-bridge.mjs": "a0058579b02c2f3a770c7ff7b59dfe41ce50761361d9a0c7a2df96ab1b6b4acf", - "consumer.mjs": "3bd1a07e87e5e367775a3deadc71cd205dfc8e9757e632f2406791ae5db7ad73", - "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", - "dashboard.html": "7af8dacc8023922eb7705d8160c583b385eb4c293fa1b0b38327e92aa816cf9c", - "export-docs.mjs": "6c25ec23b21cc0ba0caaaad35a031235eb09ced97f85a5b1ae32b5371050d7ac", - "package-lock.json": "7b7e6d588b495fd74020864325d8004ad797fb6b7fc563fc373f07c5c2501479", - "package.json": "a3ccb3acde48e0d463010724ddef9a40437ce45ccfbf997ba3942ae58b5609e3", - "provider.mjs": "67e0e5b2699c84444677f3f30c1c2a020d82976bfb203e508837a51e4d9920ae", - "scenario.mjs": "d8502ba81eb09fe42d574a38cd893c3acf6cf9ad8f43f91044e967a282920860", - "server.mjs": "b0f185a3954fbe1d7a4dd15f0dc8638afcf04e386ec730ae17a15860784d9e36", - "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", - "verify.mjs": "8d0c92285424c35f8acd1b45f9a64c1b82c4a7abf3b30587a1b20ae43b065540", - "webhooks.mjs": "6124cf3bdb4404c2f5a712ae8e9fc8e3382a169f8aa79d0e5476a7d5773b9800" - }, - "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 is explicitly unimplemented", - "Cancellation after expiry is ignored", - "Expired fixture evidence has an expired verdict", - "Tampered HTTP body has no inbox effect", - "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" - ], - "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": "350f9c76-c486-4847-ac70-0082ea7cd6ae", - "deliveryId": "957ba735-6bd4-48ae-ae1c-adf89c937d3d", - "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": "350f9c76-c486-4847-ac70-0082ea7cd6ae", - "deliveryId": "957ba735-6bd4-48ae-ae1c-adf89c937d3d", - "attempts": 0, - "status": "pending", - "eventType": "entitlement.granted" - }, - { - "eventId": "bffd02d8-d3ce-4f38-bb7c-c0827fe41314", - "deliveryId": "84cecb49-d822-4ebb-ac40-bc6a81677f6b", - "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": "350f9c76-c486-4847-ac70-0082ea7cd6ae", - "deliveryId": "957ba735-6bd4-48ae-ae1c-adf89c937d3d", - "attempts": 3, - "status": "delivered", - "eventType": "entitlement.granted" - }, - { - "eventId": "bffd02d8-d3ce-4f38-bb7c-c0827fe41314", - "deliveryId": "84cecb49-d822-4ebb-ac40-bc6a81677f6b", - "attempts": 3, - "status": "delivered", - "eventType": "subscription.canceled" - } - ], - "inboxCount": 2, - "responses": [ - { - "operation": "webhook: receiver unavailable", - "body": [ - { - "eventId": "350f9c76-c486-4847-ac70-0082ea7cd6ae", - "deliveryId": "957ba735-6bd4-48ae-ae1c-adf89c937d3d", - "httpStatus": 503, - "attempt": 1, - "status": "pending" - }, - { - "eventId": "bffd02d8-d3ce-4f38-bb7c-c0827fe41314", - "deliveryId": "84cecb49-d822-4ebb-ac40-bc6a81677f6b", - "httpStatus": 503, - "attempt": 1, - "status": "pending" - } - ] - }, - { - "operation": "webhook: retry after restart", - "body": [ - { - "eventId": "350f9c76-c486-4847-ac70-0082ea7cd6ae", - "deliveryId": "957ba735-6bd4-48ae-ae1c-adf89c937d3d", - "httpStatus": 200, - "attempt": 2, - "status": "delivered" - }, - { - "eventId": "bffd02d8-d3ce-4f38-bb7c-c0827fe41314", - "deliveryId": "84cecb49-d822-4ebb-ac40-bc6a81677f6b", - "httpStatus": 200, - "attempt": 2, - "status": "delivered" - } - ] - }, - { - "operation": "webhook: lost-ack redelivery", - "body": [ - { - "eventId": "350f9c76-c486-4847-ac70-0082ea7cd6ae", - "deliveryId": "957ba735-6bd4-48ae-ae1c-adf89c937d3d", - "httpStatus": 200, - "attempt": 3, - "status": "delivered" - }, - { - "eventId": "bffd02d8-d3ce-4f38-bb7c-c0827fe41314", - "deliveryId": "84cecb49-d822-4ebb-ac40-bc6a81677f6b", - "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": "350f9c76-c486-4847-ac70-0082ea7cd6ae", - "deliveryId": "957ba735-6bd4-48ae-ae1c-adf89c937d3d", - "attempts": 3, - "status": "delivered", - "eventType": "entitlement.granted" - }, - { - "eventId": "bffd02d8-d3ce-4f38-bb7c-c0827fe41314", - "deliveryId": "84cecb49-d822-4ebb-ac40-bc6a81677f6b", - "attempts": 3, - "status": "delivered", - "eventType": "subscription.canceled" - }, - { - "eventId": "c8afc7d1-4a64-4441-89f5-bf81b8ea42c9", - "deliveryId": "114aecd3-6327-44eb-8a55-e538ee49f08a", - "attempts": 1, - "status": "delivered", - "eventType": "subscription.expired" - }, - { - "eventId": "3ce53aa7-2ee9-48a0-a4f5-756ae376406b", - "deliveryId": "61ffb8ed-5802-419f-bb4a-2f5725096740", - "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": "c8afc7d1-4a64-4441-89f5-bf81b8ea42c9", - "deliveryId": "114aecd3-6327-44eb-8a55-e538ee49f08a", - "httpStatus": 200, - "attempt": 1, - "status": "delivered" - }, - { - "eventId": "3ce53aa7-2ee9-48a0-a4f5-756ae376406b", - "deliveryId": "61ffb8ed-5802-419f-bb4a-2f5725096740", - "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 - } - ], - "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/06-recover-reviewed-4/screen.png b/docs/build/06-recover-reviewed-4/screen.png deleted file mode 100644 index de5be8a..0000000 Binary files a/docs/build/06-recover-reviewed-4/screen.png and /dev/null differ diff --git a/docs/build/06-recover-reviewed-4/source.tar.gz b/docs/build/06-recover-reviewed-4/source.tar.gz deleted file mode 100644 index 49a6ae1..0000000 Binary files a/docs/build/06-recover-reviewed-4/source.tar.gz and /dev/null differ diff --git a/docs/build/06-recover-reviewed-5/attempt-1.txt b/docs/build/06-recover-reviewed-5/attempt-1.txt deleted file mode 100644 index bb4b6c0..0000000 --- a/docs/build/06-recover-reviewed-5/attempt-1.txt +++ /dev/null @@ -1,20 +0,0 @@ -{ - "startedAt": "2026-09-07T16:01:19.416Z", - "commands": [ - { - "command": "npm ci --ignore-scripts --no-audit --no-fund", - "exitCode": 0, - "output": "\nadded 10 packages in 358ms\n" - }, - { - "command": "npm test", - "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs\n\nCommerce Protocol Example: 133 checks passed. No store or production service contacted.\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) patches preserve path-like source text across additions, changes, and deletions [44.36ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.47ms]\n(pass) unfinished captures do not hide completed records [0.66ms]\n\n 3 pass\n 0 fail\nRan 3 tests across 1 file. [60.00ms]\n" - } - ] -} diff --git a/docs/build/06-recover-reviewed-5/changes.patch b/docs/build/06-recover-reviewed-5/changes.patch deleted file mode 100644 index f046b89..0000000 --- a/docs/build/06-recover-reviewed-5/changes.patch +++ /dev/null @@ -1,339 +0,0 @@ -diff --git a/BUILD.md b/BUILD.md -index 7362fcc..b5884a0 100644 ---- a/BUILD.md -+++ b/BUILD.md -@@ -22,8 +22,8 @@ same package name. Read these files from the installed package directory - - `generated/schemas/commerce-protocol.bundle.schema.json`: offline validation. - - `conformance/` and `vectors/`: portable checks and signature fixtures. - --For architecture, use the [implementation guide](https://openiap.dev/commerce-protocol/implementation) --and [whitepaper](https://openiap.dev/commerce-protocol/whitepaper). Package 0.1.0 -+For architecture, use the [implementation guide](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/README.md) -+and [whitepaper](https://openiap.dev/commerce-protocol-rationale.pdf). Package 0.1.0 - does not include `DESIGN.md`; read that optional file only when it exists in - your installed version. - -diff --git a/INTEGRATE.md b/INTEGRATE.md -index 49b04aa..c497c13 100644 ---- a/INTEGRATE.md -+++ b/INTEGRATE.md -@@ -13,7 +13,7 @@ several roles. These are product roles, not additional protocol profiles. - - ## Start with working code - --[Download the complete example](https://openiap.dev/commerce-example/06-recover-reviewed-4/source.tar.gz) -+[Download the complete example](https://github.com/hyodotdev/openiap-commerce-protocol-example/archive/refs/heads/main.zip) - and extract it into an empty directory. It contains `client-bridge.mjs`, - `consumer.mjs`, `webhooks.mjs`, the backend, and their executable checks. - The [example repository](https://github.com/hyodotdev/openiap-commerce-protocol-example) -@@ -31,7 +31,7 @@ or `bun install`. This example's runtime is Bun. The contract is - - The bridge and consumer can be used separately. These checks prove local - boundaries with fictional inputs, not a mobile checkout or a store adapter. --The [receiver setup guide](https://openiap.dev/commerce-protocol/getting-started#receive-events) -+The [receiver setup guide](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/receiver.md) - gives the endpoint, configuration, and limits. - - ## Task for the AI -@@ -40,8 +40,8 @@ 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 - vectors for the role being implemented. Package 0.1.0 does not ship `DESIGN.md`; --the [role guide](https://openiap.dev/commerce-protocol/ecosystem) --and [whitepaper](https://openiap.dev/commerce-protocol/whitepaper) give context. -+the [role guide](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/INTEGRATE.md) -+and [whitepaper](https://openiap.dev/commerce-protocol-rationale.pdf) give context. - - Implement the selected role using the product's existing framework and design - system. Deliver usable code and a short connection example, not a list of work -@@ -64,7 +64,7 @@ for the app team. Follow these boundaries: - 3. **Commerce:** provide core discovery and implement every operation/obligation - of each advertised profile and binding. Account lifecycle includes erasure. - Keep one authoritative ownership and entitlement service for each app/project, -- even when it delegates verification. Use [backend build brief](https://openiap.dev/commerce-example/build-brief.md) for the backend -+ even when it delegates verification. Use [backend build brief](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/BUILD.md) for the backend - implementation sequence. The fixture backend advertises no complete profiles. - 4. **Data:** reuse or port `webhooks.mjs` and `consumer.mjs`. Authenticate exact - body bytes before parsing, validate, durably deduplicate in the configured -diff --git a/README.md b/README.md -index 2834591..e253138 100644 ---- a/README.md -+++ b/README.md -@@ -1,102 +1,123 @@ - # OpenIAP Commerce Protocol example - --Build a small purchase-to-access backend with AI, one working milestone at a --time. Each checkpoint includes the task, source changes, executed checks, and --a screenshot of that version running. -+A runnable purchase-to-access backend, built and reviewed with AI in six -+milestones. Follow a purchase through verification, ownership, access, and -+signed event delivery. Inspect the actual HTTP responses and database changes. - --## Run -+The backend uses the published **`openiap-commerce-protocol`** package. HTTP, -+SQLite, and webhook signatures run locally; the store, users, and clock are -+fictional fixtures. This is a learning example, not a production provider. - --Use your favorite package manager to install dependencies: -+## Quick start -+ -+Install [Bun](https://bun.sh/docs/installation) for the HTTP and SQLite runtime -+(tested with Bun 1.3.13). Use your favorite package manager for dependencies and -+scripts; npm is shown here: - - ```sh -+git clone https://github.com/hyodotdev/openiap-commerce-protocol-example.git -+cd openiap-commerce-protocol-example - npm install # or pnpm install, yarn install, bun install - npm test - npm start - ``` - --This example uses the Bun runtime for HTTP and SQLite. Its scripts work through --any package manager with Bun installed. The included `.yarnrc.yml` selects --`node_modules` for modern Yarn so Bun can resolve the installed packages. The protocol can be implemented in your --own language and stack. Open http://127.0.0.1:5181 and run each available step. -+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, -+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. -+ -+![The completed local backend: expired access, delivered events, and inspectable responses](https://raw.githubusercontent.com/hyodotdev/openiap-commerce-protocol-example/main/docs/build/06-recover-reviewed-5/screen.png) -+ -+The screenshot comes from an executed source checkpoint. See its -+[run report](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/06-recover-reviewed-5/run.json) -+and the [complete build history](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/README.md). -+ -+## What you will see -+ -+| Step | What changes | Result to check | -+| ----------- | ------------------------------------------------ | ---------------------------------------------------- | -+| 1. Contract | Load schemas; start with empty storage | No purchases or access yet | -+| 2. Verify | Validate fixture evidence; save a purchase | Verification alone grants no access | -+| 3. Bind | Attach the purchase to the backend-selected user | Alice gains Premium; Bob cannot claim it | -+| 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 | - --## Connect your product -+Restarting `npm start` creates a fresh temporary database, so you can replay the -+walkthrough. Step 6 reopens the existing databases **inside the running process**; -+it does not simulate an OS crash or a new process recovering external secrets. -+If port 5181 is occupied, run `COMMERCE_LAB_PORT=5183 npm start`. - --Start with [INTEGRATE.md](INTEGRATE.md) to build a paywall integration, a commerce --service, an event consumer, or an integrated platform. Run `npm run demo:bridge` --to check Apple/Google OpenIAP purchase fields against the installed verification --input schema. The helper runs on the app backend; it does not perform a mobile --purchase or authenticate evidence. -+## Use the part you need - --## Receive events in an existing backend -+| Your role | Start here | What this example provides | -+| -------------------- | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -+| Paywall / experience | [Integration brief](INTEGRATE.md) | Host purchase/result boundaries; no paywall UI implementation | -+| Commerce provider | [AI build brief](BUILD.md) | Fixture verifier, ownership/access rules, REST, SQLite, and delivery | -+| 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 | - --The ready receiver verifies signatures and saves each event once in SQLite. --Run the complete local integration check: -+`client-bridge.mjs` maps Apple/Google OpenIAP purchase fields into the installed -+verification schema **on the app backend**. Run `npm run demo:bridge` to check it. -+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. -+ -+## Receive events - - ```sh - npm run demo:consumer - ``` - --It sends purchase, renewal, cancellation, expiry, refund, and entitlement --samples over HTTP, retries each delivery, rejects tampering, and reopens the --inbox. No store adapter or provider backend is needed. The inbox stores the --signed event unchanged for your existing processing pipeline. -- --To run the receiver continuously, set `COMMERCE_WEBHOOK_SECRET` to your --provider's signing secret and run `npm run consumer`. It listens at --`http://127.0.0.1:5182/webhooks/commerce`, with storage in `consumer.sqlite` --(or `COMMERCE_INBOX_PATH`). It is bound to loopback: an HTTPS reverse proxy --must forward to that local address, preserving the exact body bytes. Forwarding the public Host header is supported; --the signature authenticates delivery. Configure --one emitter/project and signing key per receiver database. Keep the inbox file --on persistent storage. `webhooks.mjs` exports the same Fetch-compatible receiver --handler for embedding in an existing server. -- --The local check proves ingestion, not store authenticity or financial analytics. --Transaction and price fields are optional; missing values are unknown. A --lifecycle event is not necessarily a new charge. Keep your own revenue and --accounting rules; the receiver preserves the provider's fields without inventing --a zero price or counting every lifecycle event as revenue. -- --## Build with AI -- --Give [BUILD.md](BUILD.md) to your AI. Ask it to add one milestone, run it, inspect --the actual screen and responses, fix any issue, and repeat the failed check. --Review the result before moving to the next milestone. -- --[docs/build](docs/build) records this development run. Every source archive is --an independently runnable checkpoint. Later functionality is absent from the --earlier source, rather than hidden behind a runtime step switch. The dashboard --replays the operations implemented at that checkpoint. -- --This implementation extends an earlier internal prototype, called Commerce Lab, --which this standalone repository replaces. It is an incremental implementation record, not a claim that an --AI produced the backend in one prompt without reference code. -- --## Scope -- --The HTTP requests, SQLite writes, ownership rules, HMAC signatures, delivery --retries, and database recovery are real local operations. The store, user, and --clock are fixtures. No real purchases or credentials are needed. Recovery --reopens both databases inside the same HTTP process; it does not test an OS --crash, process startup, or recovery of externally stored secrets. -- --This is an educational Commerce Protocol example. Real store validation, login, erasure, tenant isolation, GraphQL, --public HTTPS delivery, and full profile conformance remain separate work. -- --## Record another checkpoint -- --Recording needs Node.js with npm, Git, tar, and Google Chrome in addition to --Bun. The backend's test/start scripts use Bun only. `npm run test:tooling` --checks capture tooling separately. -- --Update `ai-task.md` with the task and review notes. Run `npm test`, inspect the --dashboard, then `npm run capture` with Google Chrome installed. Capture runs the --checks again and preserves failed attempts. Capture installs and tests the archived source in a temporary project, then --checks the desktop and mobile screens. `checkpoint.json.previous` names the --preceding archive, so patch generation also works after a fresh clone. Run --`npm run verify:checkpoints` to check all archived sources and patches from an --empty directory and save the npm execution evidence. Published source --checkpoints are immutable; --use a new directory name in `checkpoint.json` for another revision. --Verification/export skip unfinished captures without `run.json`; their failure --logs remain on disk until that checkpoint succeeds. -+This standalone check sends fictional purchase, renewal, cancellation, expiry, -+refund, and entitlement events over HTTP. It repeats deliveries, rejects -+changed signatures, and reopens the SQLite inbox. It needs no provider server. -+ -+For a persistent receiver, set `COMMERCE_WEBHOOK_SECRET` and run -+`npm run consumer`. The endpoint is `http://127.0.0.1:5182/webhooks/commerce`. -+See the [receiver guide](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/receiver.md) -+for HTTPS proxy setup, database location, processing responsibilities, and -+expected results. Event ingestion does not calculate revenue or grant access. -+ -+## Build with AI, then check the result -+ -+Give [BUILD.md](BUILD.md) to your AI for a backend, or [INTEGRATE.md](INTEGRATE.md) -+for an existing product. Ask it to implement one milestone, run it, inspect the -+screen and responses, fix a failure, and repeat that same check. -+ -+The [build record](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/README.md) -+contains independent source archives, patches, screenshots, and execution -+reports. The [review log](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/REVIEW.md) -+keeps the observed mistakes and corrections, including unfinished discovery in -+the early snapshots. This implementation grew from an earlier internal -+prototype; it was not generated from a blank project in one prompt. Task briefs -+are implementation notes, not model transcripts or editor recordings. -+ -+## Verify or record a change -+ -+| Command | Checks | -+| ---------------------------- | ------------------------------------------------------------------------- | -+| `npm test` | Current backend flow, failure cases, request mapper, and receiver | -+| `npm run test:tooling` | Capture and patch tooling | -+| `npm run verify:checkpoints` | Every archive, its exact patch chain, and an independent npm install/test | -+| `npm run capture` | A new immutable source checkpoint and actual desktop/mobile screenshots | -+ -+Backend commands require Bun. Recording also requires Node.js/npm, Git, tar, -+and Google Chrome. The [recording guide](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/recording.md) -+explains how to preserve a checkpoint and export evidence. GitHub CI runs the -+runtime, tooling, archive, and documentation-export checks. -+ -+## What remains for production -+ -+Real store validation and sandbox purchases, login, user erasure, 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. -+ -+Implement every obligation of your chosen profiles from the installed -+`SPEC.md`; add store sandbox, isolation, deployment, and recovery tests. -+IAPKit is a reference implementation, not a substitute for those checks. -+ -+MIT licensed. See [LICENSE](LICENSE). -diff --git a/ai-task.md b/ai-task.md -index 9eeb0e6..976826f 100644 ---- a/ai-task.md -+++ b/ai-task.md -@@ -38,3 +38,11 @@ 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. -diff --git a/checkpoint.json b/checkpoint.json -index 660312c..6b41ab5 100644 ---- a/checkpoint.json -+++ b/checkpoint.json -@@ -1,8 +1,8 @@ - { - "step": 6, -- "id": "06-recover-reviewed-4", -+ "id": "06-recover-reviewed-5", - "title": "Expire access and restart", -- "built": "Expiry, atomic grant events, recovery, and verified source archives", -+ "built": "Documented standalone setup, review evidence, and verified exports", - "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", -- "previous": "06-recover-reviewed-3" -+ "previous": "06-recover-reviewed-4" - } -diff --git a/export-docs.mjs b/export-docs.mjs -index ab8208f..e89ae37 100644 ---- a/export-docs.mjs -+++ b/export-docs.mjs -@@ -68,6 +68,7 @@ for (const [name, digest] of Object.entries(consumerReport.sourceHashes)) - "Record the consumer demo again before export", - ); - mkdirSync(output, { recursive: true }); -+cpSync(join(source, last.id, "source.tar.gz"), join(output, "source.tar.gz")); - for (const record of records) - cpSync(join(source, record.id), join(output, record.id), { recursive: true }); - for (const name of [ -diff --git a/package-lock.json b/package-lock.json -index 0583924..b4979f7 100644 ---- a/package-lock.json -+++ b/package-lock.json -@@ -7,6 +7,7 @@ - "": { - "name": "openiap-commerce-protocol-example", - "version": "0.0.0", -+ "license": "MIT", - "dependencies": { - "ajv": "^8.17.1", - "openiap-commerce-protocol": "0.1.0" -diff --git a/package.json b/package.json -index b2c0932..36f1f41 100644 ---- a/package.json -+++ b/package.json -@@ -19,5 +19,11 @@ - }, - "devDependencies": { - "@playwright/test": "1.62.1" -+ }, -+ "description": "A runnable, documented Commerce Protocol backend with AI build checkpoints.", -+ "license": "MIT", -+ "repository": { -+ "type": "git", -+ "url": "https://github.com/hyodotdev/openiap-commerce-protocol-example.git" - } - } diff --git a/docs/build/06-recover-reviewed-5/mobile.png b/docs/build/06-recover-reviewed-5/mobile.png deleted file mode 100644 index 7e835b9..0000000 Binary files a/docs/build/06-recover-reviewed-5/mobile.png and /dev/null differ diff --git a/docs/build/06-recover-reviewed-5/run.json b/docs/build/06-recover-reviewed-5/run.json deleted file mode 100644 index 91475b9..0000000 --- a/docs/build/06-recover-reviewed-5/run.json +++ /dev/null @@ -1,795 +0,0 @@ -{ - "step": 6, - "id": "06-recover-reviewed-5", - "title": "Expire access and restart", - "built": "Documented standalone setup, review evidence, and verified exports", - "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", - "previous": "06-recover-reviewed-4", - "startedAt": "2026-09-07T16:01:19.416Z", - "recordedAt": "2026-09-07T16:01:23.488Z", - "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", - "sourceHashes": { - ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", - ".yarnrc.yml": "473e6def86fc03638120e0c01d0c8bbab095677256460fa4ea763e5d4697f270", - "BUILD.md": "e26c2e60273fc9fed20b1a35dee1b9c309ee29f82c1b9bc902aca8f4ad2951bf", - "INTEGRATE.md": "8d08b34132af251f372d342b51d8719f5c37a282b5fea7b5c1865654e66fd8e1", - "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", - "README.md": "05f8b7379adac31f242a1ccc7d916e7c322faa4d64eb3a24756eede38dd01eea", - "ai-task.md": "1b55eb4ac4d34ac86b79c13e926b91088459eb0eabd988db8b0ceaabf7a63e04", - "capture.mjs": "ba0c11683dee687ead861d274540b0aa13a2fa8e3210be0237aabb8013ffba0b", - "checkpoint-tools.mjs": "b2cd02f33831ea09e10ed03811bfd6e687abd97255e0c2171e37d6853d5fe718", - "checkpoint-tools.test.mjs": "589fa79923392f6a1635617989501ecc46ff34d382d5af83f240e28ba66b3c61", - "checkpoint.json": "eb83dfc67e896497519250bb71fe11a382337c29825d84c5793aaeaee645db83", - "client-bridge.mjs": "a0058579b02c2f3a770c7ff7b59dfe41ce50761361d9a0c7a2df96ab1b6b4acf", - "consumer.mjs": "3bd1a07e87e5e367775a3deadc71cd205dfc8e9757e632f2406791ae5db7ad73", - "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", - "dashboard.html": "7af8dacc8023922eb7705d8160c583b385eb4c293fa1b0b38327e92aa816cf9c", - "export-docs.mjs": "9bdb33b8b54b566158345d13fd5199c5d83e362a6cc0fc0fb184772a0189ad4e", - "package-lock.json": "6f5333cc45203d6a27fec82c9370a8c25fef7faab591467dd2ff3c79acd6ae1c", - "package.json": "6ff038e4eb85ed5e17e5e7b91149232a5c62e589e7a4083f152aa18dde944c92", - "provider.mjs": "67e0e5b2699c84444677f3f30c1c2a020d82976bfb203e508837a51e4d9920ae", - "scenario.mjs": "d8502ba81eb09fe42d574a38cd893c3acf6cf9ad8f43f91044e967a282920860", - "server.mjs": "b0f185a3954fbe1d7a4dd15f0dc8638afcf04e386ec730ae17a15860784d9e36", - "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", - "verify.mjs": "8d0c92285424c35f8acd1b45f9a64c1b82c4a7abf3b30587a1b20ae43b065540", - "webhooks.mjs": "6124cf3bdb4404c2f5a712ae8e9fc8e3382a169f8aa79d0e5476a7d5773b9800" - }, - "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 is explicitly unimplemented", - "Cancellation after expiry is ignored", - "Expired fixture evidence has an expired verdict", - "Tampered HTTP body has no inbox effect", - "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" - ], - "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": "562bc519-0b06-44b6-b3ae-29eb31db4e92", - "deliveryId": "fc787349-94d6-4d19-aeae-1edd6aeef2e0", - "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": "562bc519-0b06-44b6-b3ae-29eb31db4e92", - "deliveryId": "fc787349-94d6-4d19-aeae-1edd6aeef2e0", - "attempts": 0, - "status": "pending", - "eventType": "entitlement.granted" - }, - { - "eventId": "75b6829a-1482-4359-8cec-2ebc8eb210c5", - "deliveryId": "52fd5c43-a2cd-4b1b-86f0-6c796c967ee8", - "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": "562bc519-0b06-44b6-b3ae-29eb31db4e92", - "deliveryId": "fc787349-94d6-4d19-aeae-1edd6aeef2e0", - "attempts": 3, - "status": "delivered", - "eventType": "entitlement.granted" - }, - { - "eventId": "75b6829a-1482-4359-8cec-2ebc8eb210c5", - "deliveryId": "52fd5c43-a2cd-4b1b-86f0-6c796c967ee8", - "attempts": 3, - "status": "delivered", - "eventType": "subscription.canceled" - } - ], - "inboxCount": 2, - "responses": [ - { - "operation": "webhook: receiver unavailable", - "body": [ - { - "eventId": "562bc519-0b06-44b6-b3ae-29eb31db4e92", - "deliveryId": "fc787349-94d6-4d19-aeae-1edd6aeef2e0", - "httpStatus": 503, - "attempt": 1, - "status": "pending" - }, - { - "eventId": "75b6829a-1482-4359-8cec-2ebc8eb210c5", - "deliveryId": "52fd5c43-a2cd-4b1b-86f0-6c796c967ee8", - "httpStatus": 503, - "attempt": 1, - "status": "pending" - } - ] - }, - { - "operation": "webhook: retry after restart", - "body": [ - { - "eventId": "562bc519-0b06-44b6-b3ae-29eb31db4e92", - "deliveryId": "fc787349-94d6-4d19-aeae-1edd6aeef2e0", - "httpStatus": 200, - "attempt": 2, - "status": "delivered" - }, - { - "eventId": "75b6829a-1482-4359-8cec-2ebc8eb210c5", - "deliveryId": "52fd5c43-a2cd-4b1b-86f0-6c796c967ee8", - "httpStatus": 200, - "attempt": 2, - "status": "delivered" - } - ] - }, - { - "operation": "webhook: lost-ack redelivery", - "body": [ - { - "eventId": "562bc519-0b06-44b6-b3ae-29eb31db4e92", - "deliveryId": "fc787349-94d6-4d19-aeae-1edd6aeef2e0", - "httpStatus": 200, - "attempt": 3, - "status": "delivered" - }, - { - "eventId": "75b6829a-1482-4359-8cec-2ebc8eb210c5", - "deliveryId": "52fd5c43-a2cd-4b1b-86f0-6c796c967ee8", - "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": "562bc519-0b06-44b6-b3ae-29eb31db4e92", - "deliveryId": "fc787349-94d6-4d19-aeae-1edd6aeef2e0", - "attempts": 3, - "status": "delivered", - "eventType": "entitlement.granted" - }, - { - "eventId": "75b6829a-1482-4359-8cec-2ebc8eb210c5", - "deliveryId": "52fd5c43-a2cd-4b1b-86f0-6c796c967ee8", - "attempts": 3, - "status": "delivered", - "eventType": "subscription.canceled" - }, - { - "eventId": "1310f03c-895f-424d-97d8-ce8ae6e484fa", - "deliveryId": "1dbb41e9-322a-4bb1-a30d-e0b8e5d6136c", - "attempts": 1, - "status": "delivered", - "eventType": "subscription.expired" - }, - { - "eventId": "be06cc5b-cb3a-4a48-9d77-c2d9c55ef51d", - "deliveryId": "e872cb59-8b84-4ffb-86c8-6214db406733", - "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": "1310f03c-895f-424d-97d8-ce8ae6e484fa", - "deliveryId": "1dbb41e9-322a-4bb1-a30d-e0b8e5d6136c", - "httpStatus": 200, - "attempt": 1, - "status": "delivered" - }, - { - "eventId": "be06cc5b-cb3a-4a48-9d77-c2d9c55ef51d", - "deliveryId": "e872cb59-8b84-4ffb-86c8-6214db406733", - "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 - } - ], - "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/06-recover-reviewed-5/screen.png b/docs/build/06-recover-reviewed-5/screen.png deleted file mode 100644 index d2145bc..0000000 Binary files a/docs/build/06-recover-reviewed-5/screen.png and /dev/null differ diff --git a/docs/build/06-recover-reviewed-5/source.tar.gz b/docs/build/06-recover-reviewed-5/source.tar.gz deleted file mode 100644 index 3358bd1..0000000 Binary files a/docs/build/06-recover-reviewed-5/source.tar.gz and /dev/null differ diff --git a/docs/build/06-recover-reviewed-6/attempt-1.txt b/docs/build/06-recover-reviewed-6/attempt-1.txt deleted file mode 100644 index f5b4195..0000000 --- a/docs/build/06-recover-reviewed-6/attempt-1.txt +++ /dev/null @@ -1,20 +0,0 @@ -{ - "startedAt": "2026-09-07T16:15:39.035Z", - "commands": [ - { - "command": "npm ci --ignore-scripts --no-audit --no-fund", - "exitCode": 0, - "output": "\nadded 10 packages in 356ms\n" - }, - { - "command": "npm test", - "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs\n\nCommerce Protocol Example: 133 checks passed. No store or production service contacted.\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 [10.13ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [50.25ms]\n(pass) failed attempts get new numbers and private paths are redacted [1.08ms]\n(pass) unfinished captures do not hide completed records [1.54ms]\n\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [76.00ms]\n" - } - ] -} diff --git a/docs/build/06-recover-reviewed-6/changes.patch b/docs/build/06-recover-reviewed-6/changes.patch deleted file mode 100644 index 7a9dd0a..0000000 --- a/docs/build/06-recover-reviewed-6/changes.patch +++ /dev/null @@ -1,135 +0,0 @@ -diff --git a/README.md b/README.md -index e253138..203d94e 100644 ---- a/README.md -+++ b/README.md -@@ -28,10 +28,10 @@ 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. - --![The completed local backend: expired access, delivered events, and inspectable responses](https://raw.githubusercontent.com/hyodotdev/openiap-commerce-protocol-example/main/docs/build/06-recover-reviewed-5/screen.png) -+![The completed local backend: expired access, delivered events, and inspectable responses](https://raw.githubusercontent.com/hyodotdev/openiap-commerce-protocol-example/main/docs/build/06-recover-reviewed-6/screen.png) - - The screenshot comes from an executed source checkpoint. See its --[run report](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/06-recover-reviewed-5/run.json) -+[run report](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/06-recover-reviewed-6/run.json) - and the [complete build history](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/README.md). - - ## What you will see -diff --git a/ai-task.md b/ai-task.md -index 976826f..4687819 100644 ---- a/ai-task.md -+++ b/ai-task.md -@@ -46,3 +46,8 @@ 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. -diff --git a/capture.mjs b/capture.mjs -index 564f82f..53904d7 100644 ---- a/capture.mjs -+++ b/capture.mjs -@@ -59,7 +59,9 @@ try { - ); - } - writeFileSync(join(output, "changes.patch"), createPatch(previous, current)); -- run("tar", [ -+ run("env", [ -+ "COPYFILE_DISABLE=1", -+ "tar", - "-czf", - join(output, "source.tar.gz"), - "-C", -diff --git a/checkpoint-tools.mjs b/checkpoint-tools.mjs -index a87d59a..06b5f3b 100644 ---- a/checkpoint-tools.mjs -+++ b/checkpoint-tools.mjs -@@ -85,7 +85,15 @@ export function run(command, args, cwd) { - } - export function extract(archive, target) { - mkdirSync(target, { recursive: true }); -- run("tar", ["-xzf", archive, "-C", target]); -+ // Older macOS archives contain AppleDouble metadata, not source files. -+ run("tar", [ -+ ...(process.platform === "darwin" ? ["--no-mac-metadata"] : []), -+ "--exclude=._*", -+ "-xzf", -+ archive, -+ "-C", -+ target, -+ ]); - } - export function normalizePatch(patch) { - return patch -diff --git a/checkpoint-tools.test.mjs b/checkpoint-tools.test.mjs -index e050dc9..e807fae 100644 ---- a/checkpoint-tools.test.mjs -+++ b/checkpoint-tools.test.mjs -@@ -5,11 +5,48 @@ import { tmpdir, homedir } from "node:os"; - import { join } from "node:path"; - import { - createPatch, -+ extract, -+ hashes, - nextAttempt, -+ run, - sanitize, - readRecords, - } from "./checkpoint-tools.mjs"; - -+test("archive extraction excludes macOS metadata and preserves source hashes", () => { -+ const temp = mkdtempSync(join(tmpdir(), "commerce-capture-test-")); -+ try { -+ const source = join(temp, "source"); -+ mkdirSync(source); -+ writeFileSync(join(source, "README.md"), "Source content\n"); -+ const expected = hashes(source); -+ const metadata = Buffer.alloc(70); -+ metadata.writeUInt32BE(0x00051607, 0); -+ metadata.writeUInt32BE(0x00020000, 4); -+ metadata.writeUInt16BE(1, 24); -+ metadata.writeUInt32BE(9, 26); -+ metadata.writeUInt32BE(38, 30); -+ metadata.writeUInt32BE(32, 34); -+ writeFileSync(join(source, "._README.md"), metadata); -+ const archive = join(temp, "source.tar.gz"); -+ run("env", [ -+ "COPYFILE_DISABLE=1", -+ "tar", -+ "-czf", -+ archive, -+ "-C", -+ source, -+ "._README.md", -+ "README.md", -+ ]); -+ const target = join(temp, "extracted"); -+ extract(archive, target); -+ assert.deepEqual(hashes(target), expected); -+ } finally { -+ rmSync(temp, { recursive: true, force: true }); -+ } -+}); -+ - test("patches preserve path-like source text across additions, changes, and deletions", () => { - const temp = mkdtempSync(join(tmpdir(), "commerce-patch-test-")); - try { -diff --git a/checkpoint.json b/checkpoint.json -index 6b41ab5..4b7b724 100644 ---- a/checkpoint.json -+++ b/checkpoint.json -@@ -1,8 +1,8 @@ - { - "step": 6, -- "id": "06-recover-reviewed-5", -+ "id": "06-recover-reviewed-6", - "title": "Expire access and restart", -- "built": "Documented standalone setup, review evidence, and verified exports", -+ "built": "Verified portable source archives on macOS and Linux", - "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", -- "previous": "06-recover-reviewed-4" -+ "previous": "06-recover-reviewed-5" - } diff --git a/docs/build/06-recover-reviewed-6/mobile.png b/docs/build/06-recover-reviewed-6/mobile.png deleted file mode 100644 index 7e835b9..0000000 Binary files a/docs/build/06-recover-reviewed-6/mobile.png and /dev/null differ diff --git a/docs/build/06-recover-reviewed-6/run.json b/docs/build/06-recover-reviewed-6/run.json deleted file mode 100644 index b7cfce5..0000000 --- a/docs/build/06-recover-reviewed-6/run.json +++ /dev/null @@ -1,795 +0,0 @@ -{ - "step": 6, - "id": "06-recover-reviewed-6", - "title": "Expire access and restart", - "built": "Verified portable source archives on macOS and Linux", - "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", - "previous": "06-recover-reviewed-5", - "startedAt": "2026-09-07T16:15:39.035Z", - "recordedAt": "2026-09-07T16:15:42.823Z", - "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", - "sourceHashes": { - ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", - ".yarnrc.yml": "473e6def86fc03638120e0c01d0c8bbab095677256460fa4ea763e5d4697f270", - "BUILD.md": "e26c2e60273fc9fed20b1a35dee1b9c309ee29f82c1b9bc902aca8f4ad2951bf", - "INTEGRATE.md": "8d08b34132af251f372d342b51d8719f5c37a282b5fea7b5c1865654e66fd8e1", - "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", - "README.md": "02a348f16d106eb00cfc84d134b373c734588bf2a78dbf037a01f94f22a26841", - "ai-task.md": "177b9725e5538803e2495fac2f3979d10d2fb7d93bfc082ed4e1d6b0a1fdb39b", - "capture.mjs": "1c3e785d5282359c346240c66eeaed70404758924d61fdd6f798f367988cd96c", - "checkpoint-tools.mjs": "09da9b218f7365e1e7995c5a49c6247faf7fac24463843c44d62e097d167bed0", - "checkpoint-tools.test.mjs": "9c71b5f95f33fe3352f20836a57d063ed5c680fa0837ae4de399bc820b5d3896", - "checkpoint.json": "d6b07f9090b1199865b3d5538f4e058145da418a610aee0da0577593cdf5d834", - "client-bridge.mjs": "a0058579b02c2f3a770c7ff7b59dfe41ce50761361d9a0c7a2df96ab1b6b4acf", - "consumer.mjs": "3bd1a07e87e5e367775a3deadc71cd205dfc8e9757e632f2406791ae5db7ad73", - "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", - "dashboard.html": "7af8dacc8023922eb7705d8160c583b385eb4c293fa1b0b38327e92aa816cf9c", - "export-docs.mjs": "9bdb33b8b54b566158345d13fd5199c5d83e362a6cc0fc0fb184772a0189ad4e", - "package-lock.json": "6f5333cc45203d6a27fec82c9370a8c25fef7faab591467dd2ff3c79acd6ae1c", - "package.json": "6ff038e4eb85ed5e17e5e7b91149232a5c62e589e7a4083f152aa18dde944c92", - "provider.mjs": "67e0e5b2699c84444677f3f30c1c2a020d82976bfb203e508837a51e4d9920ae", - "scenario.mjs": "d8502ba81eb09fe42d574a38cd893c3acf6cf9ad8f43f91044e967a282920860", - "server.mjs": "b0f185a3954fbe1d7a4dd15f0dc8638afcf04e386ec730ae17a15860784d9e36", - "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", - "verify.mjs": "8d0c92285424c35f8acd1b45f9a64c1b82c4a7abf3b30587a1b20ae43b065540", - "webhooks.mjs": "6124cf3bdb4404c2f5a712ae8e9fc8e3382a169f8aa79d0e5476a7d5773b9800" - }, - "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 is explicitly unimplemented", - "Cancellation after expiry is ignored", - "Expired fixture evidence has an expired verdict", - "Tampered HTTP body has no inbox effect", - "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" - ], - "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": "1dd3892d-46ec-4394-a558-c886a173a0c1", - "deliveryId": "4855d93a-800b-4a9d-8325-e37f37442ff9", - "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": "1dd3892d-46ec-4394-a558-c886a173a0c1", - "deliveryId": "4855d93a-800b-4a9d-8325-e37f37442ff9", - "attempts": 0, - "status": "pending", - "eventType": "entitlement.granted" - }, - { - "eventId": "3bcb581d-c6ad-441f-815d-2dc992e49490", - "deliveryId": "7dac1fb5-59b4-4b41-bfb2-4eedc8e97f66", - "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": "1dd3892d-46ec-4394-a558-c886a173a0c1", - "deliveryId": "4855d93a-800b-4a9d-8325-e37f37442ff9", - "attempts": 3, - "status": "delivered", - "eventType": "entitlement.granted" - }, - { - "eventId": "3bcb581d-c6ad-441f-815d-2dc992e49490", - "deliveryId": "7dac1fb5-59b4-4b41-bfb2-4eedc8e97f66", - "attempts": 3, - "status": "delivered", - "eventType": "subscription.canceled" - } - ], - "inboxCount": 2, - "responses": [ - { - "operation": "webhook: receiver unavailable", - "body": [ - { - "eventId": "1dd3892d-46ec-4394-a558-c886a173a0c1", - "deliveryId": "4855d93a-800b-4a9d-8325-e37f37442ff9", - "httpStatus": 503, - "attempt": 1, - "status": "pending" - }, - { - "eventId": "3bcb581d-c6ad-441f-815d-2dc992e49490", - "deliveryId": "7dac1fb5-59b4-4b41-bfb2-4eedc8e97f66", - "httpStatus": 503, - "attempt": 1, - "status": "pending" - } - ] - }, - { - "operation": "webhook: retry after restart", - "body": [ - { - "eventId": "1dd3892d-46ec-4394-a558-c886a173a0c1", - "deliveryId": "4855d93a-800b-4a9d-8325-e37f37442ff9", - "httpStatus": 200, - "attempt": 2, - "status": "delivered" - }, - { - "eventId": "3bcb581d-c6ad-441f-815d-2dc992e49490", - "deliveryId": "7dac1fb5-59b4-4b41-bfb2-4eedc8e97f66", - "httpStatus": 200, - "attempt": 2, - "status": "delivered" - } - ] - }, - { - "operation": "webhook: lost-ack redelivery", - "body": [ - { - "eventId": "1dd3892d-46ec-4394-a558-c886a173a0c1", - "deliveryId": "4855d93a-800b-4a9d-8325-e37f37442ff9", - "httpStatus": 200, - "attempt": 3, - "status": "delivered" - }, - { - "eventId": "3bcb581d-c6ad-441f-815d-2dc992e49490", - "deliveryId": "7dac1fb5-59b4-4b41-bfb2-4eedc8e97f66", - "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": "1dd3892d-46ec-4394-a558-c886a173a0c1", - "deliveryId": "4855d93a-800b-4a9d-8325-e37f37442ff9", - "attempts": 3, - "status": "delivered", - "eventType": "entitlement.granted" - }, - { - "eventId": "3bcb581d-c6ad-441f-815d-2dc992e49490", - "deliveryId": "7dac1fb5-59b4-4b41-bfb2-4eedc8e97f66", - "attempts": 3, - "status": "delivered", - "eventType": "subscription.canceled" - }, - { - "eventId": "58eccb4e-c568-411e-8cb3-ebcf7a64e0a3", - "deliveryId": "78a5fd5a-7b2e-493a-a542-e02c48fb43c2", - "attempts": 1, - "status": "delivered", - "eventType": "subscription.expired" - }, - { - "eventId": "499b58ee-9307-4070-9423-1cb8a408ee92", - "deliveryId": "6709ccb9-a7a9-4600-93b3-4461e4b25913", - "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": "58eccb4e-c568-411e-8cb3-ebcf7a64e0a3", - "deliveryId": "78a5fd5a-7b2e-493a-a542-e02c48fb43c2", - "httpStatus": 200, - "attempt": 1, - "status": "delivered" - }, - { - "eventId": "499b58ee-9307-4070-9423-1cb8a408ee92", - "deliveryId": "6709ccb9-a7a9-4600-93b3-4461e4b25913", - "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 - } - ], - "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/06-recover-reviewed-6/screen.png b/docs/build/06-recover-reviewed-6/screen.png deleted file mode 100644 index de5be8a..0000000 Binary files a/docs/build/06-recover-reviewed-6/screen.png and /dev/null differ diff --git a/docs/build/06-recover-reviewed-6/source.tar.gz b/docs/build/06-recover-reviewed-6/source.tar.gz deleted file mode 100644 index a5be916..0000000 Binary files a/docs/build/06-recover-reviewed-6/source.tar.gz and /dev/null differ diff --git a/docs/build/06-recover-reviewed-7/attempt-1.txt b/docs/build/06-recover-reviewed-7/attempt-1.txt deleted file mode 100644 index e41d839..0000000 --- a/docs/build/06-recover-reviewed-7/attempt-1.txt +++ /dev/null @@ -1,20 +0,0 @@ -{ - "startedAt": "2026-09-07T17:46:08.687Z", - "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\n\nCommerce Protocol Example: 133 checks passed. No store or production service contacted.\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 [26.89ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [112.13ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.78ms]\n(pass) unfinished captures do not hide completed records [1.10ms]\n\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [175.00ms]\n" - } - ] -} diff --git a/docs/build/06-recover-reviewed-7/changes.patch b/docs/build/06-recover-reviewed-7/changes.patch deleted file mode 100644 index b80b3c2..0000000 --- a/docs/build/06-recover-reviewed-7/changes.patch +++ /dev/null @@ -1,72 +0,0 @@ -diff --git a/README.md b/README.md -index 203d94e..0f28606 100644 ---- a/README.md -+++ b/README.md -@@ -28,10 +28,10 @@ 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. - --![The completed local backend: expired access, delivered events, and inspectable responses](https://raw.githubusercontent.com/hyodotdev/openiap-commerce-protocol-example/main/docs/build/06-recover-reviewed-6/screen.png) -+![The completed local backend: expired access, delivered events, and inspectable responses](https://raw.githubusercontent.com/hyodotdev/openiap-commerce-protocol-example/main/docs/build/06-recover-reviewed-7/screen.png) - - The screenshot comes from an executed source checkpoint. See its --[run report](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/06-recover-reviewed-6/run.json) -+[run report](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/06-recover-reviewed-7/run.json) - and the [complete build history](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/README.md). - - ## What you will see -diff --git a/ai-task.md b/ai-task.md -index 4687819..48a1cf9 100644 ---- a/ai-task.md -+++ b/ai-task.md -@@ -51,3 +51,7 @@ 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. -diff --git a/checkpoint.json b/checkpoint.json -index 4b7b724..b2f30bb 100644 ---- a/checkpoint.json -+++ b/checkpoint.json -@@ -1,8 +1,8 @@ - { - "step": 6, -- "id": "06-recover-reviewed-6", -+ "id": "06-recover-reviewed-7", - "title": "Expire access and restart", -- "built": "Verified portable source archives on macOS and Linux", -+ "built": "Verified cancellation at expiry and preserved review evidence", - "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", -- "previous": "06-recover-reviewed-5" -+ "previous": "06-recover-reviewed-6" - } -diff --git a/export-docs.mjs b/export-docs.mjs -index e89ae37..f7da37e 100644 ---- a/export-docs.mjs -+++ b/export-docs.mjs -@@ -29,7 +29,8 @@ const selected = guide.map((step) => { - return record; - }); - const last = selected.at(-1); --assert.equal(selected.length, 6); -+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( - Object.fromEntries( - SOURCE_FILES.sort().map((name) => [ -diff --git a/verify.mjs b/verify.mjs -index e43ff66..08731f7 100644 ---- a/verify.mjs -+++ b/verify.mjs -@@ -105,7 +105,7 @@ export async function verifyLab({ compareSigner } = {}) { - lab.runtime.provider.observe({ - id: "late-cancel", - kind: "cancel", -- occurredAt: FIXTURE.startsAt + 1000, -+ occurredAt: FIXTURE.expiresAt, - }), - false, - ); diff --git a/docs/build/06-recover-reviewed-7/mobile.png b/docs/build/06-recover-reviewed-7/mobile.png deleted file mode 100644 index 7e835b9..0000000 Binary files a/docs/build/06-recover-reviewed-7/mobile.png and /dev/null differ diff --git a/docs/build/06-recover-reviewed-7/run.json b/docs/build/06-recover-reviewed-7/run.json deleted file mode 100644 index cbe9ae4..0000000 --- a/docs/build/06-recover-reviewed-7/run.json +++ /dev/null @@ -1,795 +0,0 @@ -{ - "step": 6, - "id": "06-recover-reviewed-7", - "title": "Expire access and restart", - "built": "Verified cancellation at expiry and preserved review evidence", - "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", - "previous": "06-recover-reviewed-6", - "startedAt": "2026-09-07T17:46:08.687Z", - "recordedAt": "2026-09-07T17:46:18.774Z", - "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", - "sourceHashes": { - ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", - ".yarnrc.yml": "473e6def86fc03638120e0c01d0c8bbab095677256460fa4ea763e5d4697f270", - "BUILD.md": "e26c2e60273fc9fed20b1a35dee1b9c309ee29f82c1b9bc902aca8f4ad2951bf", - "INTEGRATE.md": "8d08b34132af251f372d342b51d8719f5c37a282b5fea7b5c1865654e66fd8e1", - "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", - "README.md": "5386aafec8fa02a2a92f0810ca471ce322bfbdcb3ba9f9db7762f48724cf0b30", - "ai-task.md": "19389a0d52d311eec078ef70788682cf96fe51753101dbd0a8b00ab1fd23ebb8", - "capture.mjs": "1c3e785d5282359c346240c66eeaed70404758924d61fdd6f798f367988cd96c", - "checkpoint-tools.mjs": "09da9b218f7365e1e7995c5a49c6247faf7fac24463843c44d62e097d167bed0", - "checkpoint-tools.test.mjs": "9c71b5f95f33fe3352f20836a57d063ed5c680fa0837ae4de399bc820b5d3896", - "checkpoint.json": "f1c6a5d30e0a88bb5d97c954da2e93f20ce03ae9aa2fc143125a74f47921f776", - "client-bridge.mjs": "a0058579b02c2f3a770c7ff7b59dfe41ce50761361d9a0c7a2df96ab1b6b4acf", - "consumer.mjs": "3bd1a07e87e5e367775a3deadc71cd205dfc8e9757e632f2406791ae5db7ad73", - "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", - "dashboard.html": "7af8dacc8023922eb7705d8160c583b385eb4c293fa1b0b38327e92aa816cf9c", - "export-docs.mjs": "4a6527b1b8a533283ae7d0597a5ef38b9eaf97e94bfc841c6e958170dc611785", - "package-lock.json": "6f5333cc45203d6a27fec82c9370a8c25fef7faab591467dd2ff3c79acd6ae1c", - "package.json": "6ff038e4eb85ed5e17e5e7b91149232a5c62e589e7a4083f152aa18dde944c92", - "provider.mjs": "67e0e5b2699c84444677f3f30c1c2a020d82976bfb203e508837a51e4d9920ae", - "scenario.mjs": "d8502ba81eb09fe42d574a38cd893c3acf6cf9ad8f43f91044e967a282920860", - "server.mjs": "b0f185a3954fbe1d7a4dd15f0dc8638afcf04e386ec730ae17a15860784d9e36", - "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", - "verify.mjs": "97d5fd64b8fc5d9a51358c81ffd56d61921ad38226d764433af129cad5b35c4d", - "webhooks.mjs": "6124cf3bdb4404c2f5a712ae8e9fc8e3382a169f8aa79d0e5476a7d5773b9800" - }, - "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 is explicitly unimplemented", - "Cancellation after expiry is ignored", - "Expired fixture evidence has an expired verdict", - "Tampered HTTP body has no inbox effect", - "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" - ], - "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": "de58bc39-61f5-4eab-b636-fec420b429ef", - "deliveryId": "b02e2380-34de-41be-b737-5d3b71c473e3", - "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": "de58bc39-61f5-4eab-b636-fec420b429ef", - "deliveryId": "b02e2380-34de-41be-b737-5d3b71c473e3", - "attempts": 0, - "status": "pending", - "eventType": "entitlement.granted" - }, - { - "eventId": "ffd18094-7b16-423a-a156-ac6a1af20120", - "deliveryId": "1f228da2-292b-4014-9fdf-c3f58ef8c0ff", - "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": "de58bc39-61f5-4eab-b636-fec420b429ef", - "deliveryId": "b02e2380-34de-41be-b737-5d3b71c473e3", - "attempts": 3, - "status": "delivered", - "eventType": "entitlement.granted" - }, - { - "eventId": "ffd18094-7b16-423a-a156-ac6a1af20120", - "deliveryId": "1f228da2-292b-4014-9fdf-c3f58ef8c0ff", - "attempts": 3, - "status": "delivered", - "eventType": "subscription.canceled" - } - ], - "inboxCount": 2, - "responses": [ - { - "operation": "webhook: receiver unavailable", - "body": [ - { - "eventId": "de58bc39-61f5-4eab-b636-fec420b429ef", - "deliveryId": "b02e2380-34de-41be-b737-5d3b71c473e3", - "httpStatus": 503, - "attempt": 1, - "status": "pending" - }, - { - "eventId": "ffd18094-7b16-423a-a156-ac6a1af20120", - "deliveryId": "1f228da2-292b-4014-9fdf-c3f58ef8c0ff", - "httpStatus": 503, - "attempt": 1, - "status": "pending" - } - ] - }, - { - "operation": "webhook: retry after restart", - "body": [ - { - "eventId": "de58bc39-61f5-4eab-b636-fec420b429ef", - "deliveryId": "b02e2380-34de-41be-b737-5d3b71c473e3", - "httpStatus": 200, - "attempt": 2, - "status": "delivered" - }, - { - "eventId": "ffd18094-7b16-423a-a156-ac6a1af20120", - "deliveryId": "1f228da2-292b-4014-9fdf-c3f58ef8c0ff", - "httpStatus": 200, - "attempt": 2, - "status": "delivered" - } - ] - }, - { - "operation": "webhook: lost-ack redelivery", - "body": [ - { - "eventId": "de58bc39-61f5-4eab-b636-fec420b429ef", - "deliveryId": "b02e2380-34de-41be-b737-5d3b71c473e3", - "httpStatus": 200, - "attempt": 3, - "status": "delivered" - }, - { - "eventId": "ffd18094-7b16-423a-a156-ac6a1af20120", - "deliveryId": "1f228da2-292b-4014-9fdf-c3f58ef8c0ff", - "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": "de58bc39-61f5-4eab-b636-fec420b429ef", - "deliveryId": "b02e2380-34de-41be-b737-5d3b71c473e3", - "attempts": 3, - "status": "delivered", - "eventType": "entitlement.granted" - }, - { - "eventId": "ffd18094-7b16-423a-a156-ac6a1af20120", - "deliveryId": "1f228da2-292b-4014-9fdf-c3f58ef8c0ff", - "attempts": 3, - "status": "delivered", - "eventType": "subscription.canceled" - }, - { - "eventId": "e392d8ff-13ec-45b4-8d94-52b9b20fc212", - "deliveryId": "31872ba2-2d69-4d0b-ad8c-4cfb655faf8e", - "attempts": 1, - "status": "delivered", - "eventType": "subscription.expired" - }, - { - "eventId": "d77c7a39-0b01-4030-b947-d3d639a8970c", - "deliveryId": "c49a0ae2-d124-45ea-a585-da6254ac36b0", - "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": "e392d8ff-13ec-45b4-8d94-52b9b20fc212", - "deliveryId": "31872ba2-2d69-4d0b-ad8c-4cfb655faf8e", - "httpStatus": 200, - "attempt": 1, - "status": "delivered" - }, - { - "eventId": "d77c7a39-0b01-4030-b947-d3d639a8970c", - "deliveryId": "c49a0ae2-d124-45ea-a585-da6254ac36b0", - "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 - } - ], - "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/06-recover-reviewed-7/screen.png b/docs/build/06-recover-reviewed-7/screen.png deleted file mode 100644 index de5be8a..0000000 Binary files a/docs/build/06-recover-reviewed-7/screen.png and /dev/null differ diff --git a/docs/build/06-recover-reviewed-7/source.tar.gz b/docs/build/06-recover-reviewed-7/source.tar.gz deleted file mode 100644 index 8ee0b10..0000000 Binary files a/docs/build/06-recover-reviewed-7/source.tar.gz and /dev/null differ diff --git a/docs/build/06-recover-reviewed-8/attempt-1.txt b/docs/build/06-recover-reviewed-8/attempt-1.txt deleted file mode 100644 index c2ec2f1..0000000 --- a/docs/build/06-recover-reviewed-8/attempt-1.txt +++ /dev/null @@ -1,20 +0,0 @@ -{ - "startedAt": "2026-09-07T18:19:57.597Z", - "commands": [ - { - "command": "npm ci --ignore-scripts --no-audit --no-fund", - "exitCode": 0, - "output": "\nadded 10 packages in 415ms\n" - }, - { - "command": "npm test", - "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs\n\nCommerce Protocol Example: 138 checks passed. No store or production service contacted.\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 [10.89ms]\n(pass) patches preserve path-like source text across additions, changes, and deletions [45.03ms]\n(pass) failed attempts get new numbers and private paths are redacted [0.60ms]\n(pass) unfinished captures do not hide completed records [0.63ms]\n\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [76.00ms]\n" - } - ] -} diff --git a/docs/build/06-recover-reviewed-8/byte-auth-after.txt b/docs/build/06-recover-reviewed-8/byte-auth-after.txt deleted file mode 100644 index b96cb54..0000000 --- a/docs/build/06-recover-reviewed-8/byte-auth-after.txt +++ /dev/null @@ -1,2 +0,0 @@ -{"label":"UTF-8 replacement","httpStatus":401,"inbox":0} -{"label":"Inserted BOM","httpStatus":401,"inbox":0} diff --git a/docs/build/06-recover-reviewed-8/byte-auth-before.txt b/docs/build/06-recover-reviewed-8/byte-auth-before.txt deleted file mode 100644 index 108fb2e..0000000 --- a/docs/build/06-recover-reviewed-8/byte-auth-before.txt +++ /dev/null @@ -1,2 +0,0 @@ -{"label":"UTF-8 replacement","httpStatus":200,"inbox":1} -{"label":"Inserted BOM","httpStatus":200,"inbox":1} diff --git a/docs/build/06-recover-reviewed-8/changes.patch b/docs/build/06-recover-reviewed-8/changes.patch deleted file mode 100644 index 17aa2f3..0000000 --- a/docs/build/06-recover-reviewed-8/changes.patch +++ /dev/null @@ -1,167 +0,0 @@ -diff --git a/README.md b/README.md -index 0f28606..ec1ec9a 100644 ---- a/README.md -+++ b/README.md -@@ -28,10 +28,10 @@ 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. - --![The completed local backend: expired access, delivered events, and inspectable responses](https://raw.githubusercontent.com/hyodotdev/openiap-commerce-protocol-example/main/docs/build/06-recover-reviewed-7/screen.png) -+![The completed local backend: expired access, delivered events, and inspectable responses](https://raw.githubusercontent.com/hyodotdev/openiap-commerce-protocol-example/main/docs/build/06-recover-reviewed-8/screen.png) - - The screenshot comes from an executed source checkpoint. See its --[run report](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/06-recover-reviewed-7/run.json) -+[run report](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/06-recover-reviewed-8/run.json) - and the [complete build history](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/README.md). - - ## What you will see -diff --git a/ai-task.md b/ai-task.md -index 48a1cf9..7a2e80f 100644 ---- a/ai-task.md -+++ b/ai-task.md -@@ -55,3 +55,9 @@ 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. -diff --git a/checkpoint.json b/checkpoint.json -index b2f30bb..5ee086d 100644 ---- a/checkpoint.json -+++ b/checkpoint.json -@@ -1,8 +1,8 @@ - { - "step": 6, -- "id": "06-recover-reviewed-7", -+ "id": "06-recover-reviewed-8", - "title": "Expire access and restart", -- "built": "Verified cancellation at expiry and preserved review evidence", -+ "built": "Exact-byte webhook authentication with Unicode regression checks", - "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", -- "previous": "06-recover-reviewed-6" -+ "previous": "06-recover-reviewed-7" - } -diff --git a/verify.mjs b/verify.mjs -index 08731f7..0c0f246 100644 ---- a/verify.mjs -+++ b/verify.mjs -@@ -7,7 +7,7 @@ import { requestOperation } from "./scenario.mjs"; - import { runConsumerDemo } from "./consumer.mjs"; - import { runBridgeDemo } from "./client-bridge.mjs"; - import { startLab } from "./server.mjs"; --import { authentic, deliver, sign } from "./webhooks.mjs"; -+import { authentic, createReceiver, deliver, sign } from "./webhooks.mjs"; - - export async function verifyLab({ compareSigner } = {}) { - const lab = startLab(); -@@ -133,6 +133,65 @@ export async function verifyLab({ compareSigner } = {}) { - }), - ); - check("Tampered HTTP body has no inbox effect", invalid.status, 401); -+ const byteReceiver = createReceiver( -+ ":memory:", -+ lab.runtime.secret, -+ () => lab.runtime.time, -+ ); -+ try { -+ const event = { ...JSON.parse(body), userId: "demo_\uFFFD" }; -+ const unicode = Buffer.from(JSON.stringify(event)); -+ const offset = unicode.indexOf(Buffer.from("\uFFFD")); -+ const malformed = Buffer.concat([ -+ unicode.subarray(0, offset), -+ Buffer.from([0xff]), -+ unicode.subarray(offset + 3), -+ ]); -+ const withBom = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), unicode]); -+ const send = (bytes, signedBytes = unicode) => -+ byteReceiver.fetch( -+ new Request(`${lab.runtime.baseUrl}/demo/receiver`, { -+ method: "POST", -+ body: bytes, -+ headers: { -+ [WEBHOOK.timestampHeader]: timestamp, -+ [WEBHOOK.signatureHeader]: sign( -+ lab.runtime.secret, -+ timestamp, -+ signedBytes, -+ ), -+ [WEBHOOK.eventIdHeader]: event.eventId, -+ }, -+ }), -+ ); -+ for (const [label, bytes] of [ -+ ["Changed UTF-8 bytes", malformed], -+ ["Inserted UTF-8 BOM", withBom], -+ ]) { -+ check( -+ `${label} cannot reuse a signature`, -+ [(await send(bytes)).status, byteReceiver.count()], -+ [401, 0], -+ ); -+ } -+ check( -+ "Authenticated malformed UTF-8 is rejected before storage", -+ [(await send(malformed, malformed)).status, byteReceiver.count()], -+ [400, 0], -+ ); -+ check( -+ "Authentic Unicode bytes are accepted and stored", -+ [(await send(unicode)).status, byteReceiver.count()], -+ [200, 1], -+ ); -+ check( -+ "Authentic BOM bytes are verified before decoding", -+ [(await send(withBom, withBom)).status, byteReceiver.count()], -+ [200, 1], -+ ); -+ } finally { -+ byteReceiver.close(); -+ } - check( - "Receiver still has exactly four events", - lab.runtime.receiver.count(), -diff --git a/webhooks.mjs b/webhooks.mjs -index 796dc79..d7035a2 100644 ---- a/webhooks.mjs -+++ b/webhooks.mjs -@@ -6,7 +6,10 @@ import { validate } from "./contract.mjs"; - export function sign(secret, timestamp, body) { - return ( - WEBHOOK.signaturePrefix + -- createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex") -+ createHmac("sha256", secret) -+ .update(`${timestamp}.`) -+ .update(body) -+ .digest("hex") - ); - } - -@@ -36,20 +39,21 @@ export function createReceiver(path, secret, now) { - ); - - async function fetch(request) { -- const body = await request.text(); -+ const bytes = new Uint8Array(await request.arrayBuffer()); - if ( - !authentic( - [secret], - request.headers.get(WEBHOOK.timestampHeader), -- body, -+ bytes, - request.headers.get(WEBHOOK.signatureHeader), - Math.floor(now() / 1000), - ) - ) { - return new Response("Invalid signature", { status: 401 }); - } -- let event; -+ let body, event; - try { -+ body = new TextDecoder("utf-8", { fatal: true }).decode(bytes); - event = JSON.parse(body); - } catch { - return new Response("Invalid JSON", { status: 400 }); diff --git a/docs/build/06-recover-reviewed-8/mobile.png b/docs/build/06-recover-reviewed-8/mobile.png deleted file mode 100644 index 7e835b9..0000000 Binary files a/docs/build/06-recover-reviewed-8/mobile.png and /dev/null differ diff --git a/docs/build/06-recover-reviewed-8/screen.png b/docs/build/06-recover-reviewed-8/screen.png deleted file mode 100644 index de5be8a..0000000 Binary files a/docs/build/06-recover-reviewed-8/screen.png and /dev/null differ diff --git a/docs/build/06-recover-reviewed-8/source.tar.gz b/docs/build/06-recover-reviewed-8/source.tar.gz deleted file mode 100644 index c5c569d..0000000 Binary files a/docs/build/06-recover-reviewed-8/source.tar.gz and /dev/null differ diff --git a/docs/build/06-recover-reviewed/attempt-1.txt b/docs/build/06-recover-reviewed/attempt-1.txt deleted file mode 100644 index 7514fdd..0000000 --- a/docs/build/06-recover-reviewed/attempt-1.txt +++ /dev/null @@ -1,15 +0,0 @@ -{ - "startedAt": "2026-09-07T13:18:29.899Z", - "commands": [ - { - "command": "npm ci --ignore-scripts --no-audit --no-fund", - "exitCode": 0, - "output": "\nadded 10 packages in 354ms\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 (52.882417ms)\nℹ tests 1\nℹ suites 0\nℹ pass 1\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 98.310708\n" - } - ] -} diff --git a/docs/build/06-recover-reviewed/changes.patch b/docs/build/06-recover-reviewed/changes.patch deleted file mode 100644 index 5c06e1a..0000000 --- a/docs/build/06-recover-reviewed/changes.patch +++ /dev/null @@ -1,657 +0,0 @@ -diff --git a/BUILD.md b/BUILD.md -index 7336624..a869c9b 100644 ---- a/BUILD.md -+++ b/BUILD.md -@@ -40,6 +40,9 @@ Build these milestones in order: - 1. **Contract:** serve capabilities and validate requests and responses against - the generated artifacts. Start with an empty persistent database. Advertise - only demonstrated support; do not claim partially implemented profiles. -+ Protocol 0.1.0 requires a nonempty event list. Until an event emitter exists, -+ treat this as unfinished scaffolding, not a provider ready for integration. -+ Core discovery cannot use `UNSUPPORTED_PROFILE` as a valid fallback. - 2. **Verification:** accept known fixture evidence, reject invalid evidence, and - distinguish an upstream outage from a negative verdict. Persist a purchase - without binding a user or granting account access. -@@ -84,4 +87,12 @@ also run real store sandbox, recovery, isolation, and load tests. Treat IAPKit a - an implementation example, never as a replacement for the protocol's contract. - - Recorded local example and reproduction instructions: --https://github.com/hyodotdev/openiap/tree/main/packages/kit/examples/commerce-protocol -+https://github.com/hyodotdev/openiap-commerce-protocol-example -+ -+## Review each visible result -+ -+Inspect the running UI and the actual response and storage changes after every -+milestone. Keep a short record of the issue, the code or design correction, and -+the result of repeating the same check. Keep failed attempts as evidence. Do -+not invent failures for the story or mark a milestone complete from a screenshot -+alone. Save each runnable source checkpoint before adding the next feature. -diff --git a/README.md b/README.md -index d643942..b248bf5 100644 ---- a/README.md -+++ b/README.md -@@ -37,7 +37,9 @@ AI produced the backend in one prompt without reference code. - - The HTTP requests, SQLite writes, ownership rules, HMAC signatures, delivery - retries, and database recovery are real local operations. The store, user, and --clock are fixtures. No real purchases or credentials are needed. -+clock are fixtures. No real purchases or credentials are needed. Recovery -+reopens both databases inside the same HTTP process; it does not test an OS -+crash, process startup, or recovery of externally stored secrets. - - This is an educational part of the IAPKit architecture, not a production - provider. Real store validation, login, erasure, tenant isolation, GraphQL, -@@ -47,5 +49,10 @@ public HTTPS delivery, and full profile conformance remain separate work. - - Update `ai-task.md` with the task and review notes. Run `npm test`, inspect the - dashboard, then `npm run capture` with Google Chrome installed. Capture runs the --checks again and preserves failed attempts. Published checkpoints are immutable; -+checks again and preserves failed attempts. Capture installs and tests the archived source in a temporary project, then -+checks the desktop and mobile screens. `checkpoint.json.previous` names the -+preceding archive, so patch generation also works after a fresh clone. Run -+`npm run verify:checkpoints` to check all archived sources and patches from an -+empty directory and save the npm execution evidence. Published source -+checkpoints are immutable; - use a new directory name in `checkpoint.json` for another revision. -diff --git a/ai-task.md b/ai-task.md -index 1c910ca..1d56736 100644 ---- a/ai-task.md -+++ b/ai-task.md -@@ -1,7 +1,15 @@ --# Expire access and restart -+# Review the completed backend - --Add expiry observations and recovery checks. Recheck access at the exact deadline before a notification arrives. Prove duplicate and late observations cannot reopen access. Review the full implementation and repeat failing checks after every correction. -+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. - --Use the existing reviewed prototype where it satisfies the installed contract. --Run the backend, inspect the result, correct problems, and rerun the affected check. --Keep all work uncommitted. -+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. -diff --git a/capture.mjs b/capture.mjs -index 7a4ffc3..29eefe8 100644 ---- a/capture.mjs -+++ b/capture.mjs -@@ -1,47 +1,42 @@ - import assert from 'node:assert/strict'; --import { createHash } from 'node:crypto'; --import { spawnSync } from 'node:child_process'; --import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, cpSync, rmSync } from 'node:fs'; -+import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, readdirSync, cpSync, rmSync } from 'node:fs'; -+import { tmpdir } from 'node:os'; - import { join } from 'node:path'; -+import { pathToFileURL } from 'node:url'; - import { chromium } from '@playwright/test'; --import { startLab } from './server.mjs'; --import { verifyLab } from './verify.mjs'; --import { STAGES } from './scenario.mjs'; -+import { SOURCE_FILES, createPatch, extract, hashes, run, sanitize, sha256 } from './checkpoint-tools.mjs'; - - const root = import.meta.dir; - const checkpoint = JSON.parse(readFileSync(join(root, 'checkpoint.json'), 'utf8')); -+assert(/^\d\d-[\w-]+$/.test(checkpoint.id), 'Use a simple checkpoint directory name'); - const output = join(root, 'docs/build', checkpoint.id); - mkdirSync(output, { recursive: true }); - assert(!existsSync(join(output, 'run.json')), 'Checkpoint already published; choose a new checkpoint id.'); - const attempt = readdirSync(output).filter(name => name.startsWith('attempt-')).length + 1; -+const temp = mkdtempSync(join(tmpdir(), 'commerce-capture-')); -+const current = join(temp, 'after'), previous = join(temp, 'before'); - const startedAt = new Date().toISOString(); --let checks; --try { -- checks = await verifyLab(); -- writeFileSync(join(output, `attempt-${attempt}.txt`), JSON.stringify({ startedAt, command: 'npm run capture', verifier: 'verifyLab() (also used by npm test)', passed: checks.length, checks }, null, 2)); --} catch (error) { -- writeFileSync(join(output, `attempt-${attempt}.txt`), `${startedAt}\n${error.stack}\n`); -- throw error; --} -- --const files = readdirSync(root).filter(name => /\.(mjs|html|json|md)$/.test(name) || name === 'LICENSE' || name === '.gitignore'); --const cache = join(root, '.build-cache'); --const current = join(cache, 'after'); --const previous = join(cache, 'before'); --mkdirSync(previous, { recursive: true }); --rmSync(current, { recursive: true, force: true }); --mkdirSync(current, { recursive: true }); --for (const name of files) cpSync(join(root, name), join(current, name)); --const diff = spawnSync('git', ['diff', '--no-index', '--no-ext-diff', '--', 'before', 'after'], { cwd: cache, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }); --assert([0, 1].includes(diff.status), diff.stderr); --const patch = diff.stdout.replaceAll('a/before/', 'a/').replaceAll('b/after/', 'b/').replaceAll('a/after/', 'a/').replaceAll('b/before/', 'b/'); --writeFileSync(join(output, 'changes.patch'), patch); --const archive = spawnSync('tar', ['-czf', join(output, 'source.tar.gz'), '-C', current, ...files]); --assert.equal(archive.status, 0, archive.stderr?.toString()); -- --const lab = startLab(); --const browser = await chromium.launch({ channel: 'chrome' }); -+let lab, browser; - try { -+ mkdirSync(current); mkdirSync(previous); -+ for (const name of SOURCE_FILES) cpSync(join(root, name), join(current, name)); -+ const sourceHashes = hashes(current); -+ if (checkpoint.previous) { -+ assert(/^\d\d-[\w-]+$/.test(checkpoint.previous)); -+ const prior = join(root, 'docs/build', checkpoint.previous); -+ extract(join(prior, 'source.tar.gz'), previous); -+ assert.deepEqual(hashes(previous), JSON.parse(readFileSync(join(prior, 'run.json'), 'utf8')).sourceHashes); -+ } -+ writeFileSync(join(output, 'changes.patch'), createPatch(previous, current)); -+ run('tar', ['-czf', join(output, 'source.tar.gz'), '-C', current, ...SOURCE_FILES]); -+ const commands = [run('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], current), run('npm', ['test'], current)]; -+ writeFileSync(join(output, `attempt-${attempt}.txt`), JSON.stringify({ startedAt, commands }, null, 2) + '\n'); -+ const { startLab } = await import(pathToFileURL(join(current, 'server.mjs')).href); -+ const { verifyLab } = await import(pathToFileURL(join(current, 'verify.mjs')).href); -+ const { STAGES } = await import(pathToFileURL(join(current, 'scenario.mjs')).href); -+ const checks = await verifyLab(); -+ lab = startLab(); -+ browser = await chromium.launch({ channel: 'chrome' }); - const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } }); - const errors = []; - page.on('pageerror', error => errors.push(error.message)); -@@ -50,45 +45,38 @@ try { - await page.getByRole('button', { name: `Run step ${step} →`, exact: true }).click(); - await page.locator('#progress').filter({ hasText: `Milestone ${step} / ${STAGES.length}` }).waitFor(); - } -- assert.deepEqual(errors, []); -- assert.equal(await page.locator('#error').textContent(), ''); -- assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); -- const responses = page.locator('#responses details'); -- if (await responses.count()) { -- const wasOpen = (await responses.last().getAttribute('open')) !== null; -- if (wasOpen) await responses.last().locator('summary').click(); -- assert.equal(await responses.last().getAttribute('open'), null); -- await responses.last().locator('summary').click(); -- assert.equal(await responses.last().getAttribute('open'), ''); -- assert(await responses.last().locator('pre').isVisible()); -- if (!wasOpen) await responses.last().locator('summary').click(); -+ for (const [filename, viewport] of [['screen.png', { width: 1280, height: 1000 }], ['mobile.png', { width: 390, height: 844 }]]) { -+ await page.setViewportSize(viewport); -+ assert.equal(await page.locator('#error').textContent(), ''); -+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); -+ const responses = page.locator('#responses details'); -+ if (await responses.count()) { -+ const wasOpen = await responses.last().getAttribute('open') !== null; -+ if (wasOpen) await responses.last().locator('summary').click(); -+ assert.equal(await responses.last().getAttribute('open'), null); -+ await responses.last().locator('summary').click(); -+ assert(await responses.last().locator('pre').isVisible()); -+ if (!wasOpen) await responses.last().locator('summary').click(); -+ } -+ await page.screenshot({ path: join(output, filename), fullPage: true }); - } -- await page.screenshot({ path: join(output, 'screen.png'), fullPage: true }); -- await page.setViewportSize({ width: 390, height: 844 }); -- assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); -- await page.screenshot({ path: join(output, 'mobile.png'), fullPage: true }); -- const history = lab.scenario.history; -+ assert.deepEqual(errors, []); -+ for (const name of SOURCE_FILES) assert.equal(sha256(join(current, name)), sourceHashes[name], 'Captured source changed during verification'); - const record = { -- ...checkpoint, -- startedAt, -- recordedAt: new Date().toISOString(), -- packageVersion: JSON.parse(readFileSync(join(root, 'node_modules/openiap-commerce-protocol/package.json'), 'utf8')).version, -- task: readFileSync(join(root, 'ai-task.md'), 'utf8'), -- sourceHashes: Object.fromEntries(files.map(name => [name, createHash('sha256').update(readFileSync(join(current, name))).digest('hex')])), -- checks, -- history, -- screenshot: 'screen.png', -- scope: 'Incremental source checkpoint adapted from the reviewed Commerce Lab prototype. HTTP and SQLite execute locally; store and clock are fixtures. No full profile claim.', -+ ...checkpoint, startedAt, recordedAt: new Date().toISOString(), -+ packageVersion: JSON.parse(readFileSync(join(current, 'node_modules/openiap-commerce-protocol/package.json'), 'utf8')).version, -+ task: readFileSync(join(current, 'ai-task.md'), 'utf8'), sourceHashes, -+ checks, history: lab.scenario.history, screenshot: 'screen.png', -+ scope: 'Reviewed source checkpoint adapted from the Commerce Lab prototype. HTTP, SQLite and signatures execute locally; store and clock are fixtures. No full profile claim.', - }; - writeFileSync(join(output, 'run.json'), JSON.stringify(record, null, 2) + '\n'); -- rmSync(previous, { recursive: true, force: true }); -- cpSync(current, previous, { recursive: true }); -- console.log(`Captured ${checkpoint.id}: ${checks.length} checks, ${files.length} source files.`); -+ console.log(`Captured ${checkpoint.id}: ${checks.length} checks, ${SOURCE_FILES.length} source files.`); - } catch (error) { -- writeFileSync(join(output, `browser-failure-${attempt}.txt`), `${new Date().toISOString()}\n${error.stack}\n`); -+ writeFileSync(join(output, `failure-${attempt}.txt`), `${new Date().toISOString()}\n${sanitize(error.stack)}\n`); - throw error; - } finally { -- await browser.close(); -- await lab.close(); -- rmSync(lab.directory, { recursive: true, force: true }); -+ await browser?.close(); -+ await lab?.close(); -+ rmSync(temp, { recursive: true, force: true }); -+ if (lab) rmSync(lab.directory, { recursive: true, force: true }); - } -diff --git a/checkpoint-tools.mjs b/checkpoint-tools.mjs -new file mode 100644 -index 0000000..d620ac1 ---- /dev/null -+++ b/checkpoint-tools.mjs -@@ -0,0 +1,53 @@ -+import assert from 'node:assert/strict'; -+import { createHash } from 'node:crypto'; -+import { spawnSync } from 'node:child_process'; -+import { cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; -+import { tmpdir } from 'node:os'; -+import { join } from 'node:path'; -+ -+export const SOURCE_FILES = [ -+ '.gitignore', 'LICENSE', 'README.md', 'BUILD.md', 'ai-task.md', 'checkpoint.json', -+ 'package.json', 'package-lock.json', 'contract.mjs', 'provider.mjs', 'webhooks.mjs', -+ 'scenario.mjs', 'server.mjs', 'verify.mjs', 'dashboard.html', 'capture.mjs', -+ 'export-docs.mjs', 'checkpoint-tools.mjs', 'checkpoint-tools.test.mjs', 'verify-checkpoints.mjs', -+]; -+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 const sanitize = text => text.replaceAll(process.cwd(), '').replace(/\/[^\s"']*\/commerce-(?:capture|patch|verify)-[^\s/"']+/g, ''); -+export function run(command, args, cwd) { -+ const result = spawnSync(command, args, { cwd, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }); -+ const output = sanitize((result.stdout ?? '') + (result.stderr ?? '')); -+ assert.equal(result.status, 0, `${command} ${args.join(' ')}\n${output}`); -+ return { command: [command, ...args].join(' '), exitCode: result.status, output }; -+} -+export function extract(archive, target) { -+ mkdirSync(target, { recursive: true }); -+ run('tar', ['-xzf', archive, '-C', target]); -+} -+export function normalizePatch(patch) { -+ return patch.split('\n').map(line => { -+ if (/^(diff --git |--- a\/|\+\+\+ b\/)/.test(line)) { -+ return line.replace(/([ab])\/(?:before|after)\//g, '$1/'); -+ } -+ return line; -+ }).join('\n'); -+} -+export function createPatch(before, after) { -+ const temp = mkdtempSync(join(tmpdir(), 'commerce-patch-')); -+ try { -+ cpSync(before, join(temp, 'before'), { recursive: true }); -+ cpSync(after, join(temp, 'after'), { recursive: true }); -+ const result = spawnSync('git', ['diff', '--no-index', '--no-ext-diff', '--', 'before', 'after'], { cwd: temp, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }); -+ assert([0, 1].includes(result.status), result.stderr); -+ const patch = normalizePatch(result.stdout); -+ writeFileSync(join(temp, 'changes.patch'), patch); -+ cpSync(before, join(temp, 'applied'), { recursive: true }); -+ if (patch) run('git', ['apply', join(temp, 'changes.patch')], join(temp, 'applied')); -+ assert.deepEqual(hashes(join(temp, 'applied')), hashes(after), 'Patch must reproduce the source archive exactly'); -+ return patch; -+ } finally { rmSync(temp, { recursive: true, force: true }); } -+} -+export function readRecords(directory) { -+ return readdirSync(directory).filter(name => /^\d\d-[\w-]+$/.test(name)).sort() -+ .map(name => JSON.parse(readFileSync(join(directory, name, 'run.json'), 'utf8'))); -+} -diff --git a/checkpoint-tools.test.mjs b/checkpoint-tools.test.mjs -new file mode 100644 -index 0000000..d467420 ---- /dev/null -+++ b/checkpoint-tools.test.mjs -@@ -0,0 +1,19 @@ -+import assert from 'node:assert/strict'; -+import { test } from 'node:test'; -+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -+import { tmpdir } from 'node:os'; -+import { join } from 'node:path'; -+import { createPatch } from './checkpoint-tools.mjs'; -+ -+test('patches preserve path-like source text across additions, changes, and deletions', () => { -+ const temp = mkdtempSync(join(tmpdir(), 'commerce-patch-test-')); -+ try { -+ const before = join(temp, 'before'), after = join(temp, 'after'); -+ mkdirSync(before); mkdirSync(after); -+ writeFileSync(join(after, 'capture.mjs'), "const paths = ['a/before/', 'b/after/'];\n"); -+ assert.match(createPatch(before, after), /\+const paths = \['a\/before\/', 'b\/after\/'\];/); -+ writeFileSync(join(before, 'capture.mjs'), 'old\n'); -+ writeFileSync(join(before, 'removed.md'), 'remove\n'); -+ assert.match(createPatch(before, after), /deleted file mode/); -+ } finally { rmSync(temp, { recursive: true, force: true }); } -+}); -diff --git a/checkpoint.json b/checkpoint.json -index 36c56ba..576f357 100644 ---- a/checkpoint.json -+++ b/checkpoint.json -@@ -1,7 +1,8 @@ - { - "step": 6, -- "id": "06-recover", -+ "id": "06-recover-reviewed", - "title": "Expire access and restart", -- "built": "Expiry-aware reads + recovery from SQLite", -- "result": "Access closes at the deadline. Restarting preserves purchases and deliveries." -+ "built": "Expiry, atomic grant events, recovery, and verified source archives", -+ "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", -+ "previous": "06-recover" - } -diff --git a/export-docs.mjs b/export-docs.mjs -new file mode 100644 -index 0000000..82cf485 ---- /dev/null -+++ b/export-docs.mjs -@@ -0,0 +1,51 @@ -+import assert from 'node:assert/strict'; -+import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -+import { tmpdir } from 'node:os'; -+import { join, resolve } from 'node:path'; -+import { SOURCE_FILES, extract, hashes, readRecords, sha256 } from './checkpoint-tools.mjs'; -+ -+const target = process.argv[2]; -+assert(target, 'Usage: bun export-docs.mjs '); -+const output = resolve(target); -+const source = join(import.meta.dir, 'docs/build'); -+const guide = JSON.parse(readFileSync(join(source, 'guide.json'), 'utf8')); -+const records = readRecords(source); -+const selected = guide.map(step => { -+ const record = records.find(record => record.id === step.id); -+ assert(record, `Missing checkpoint: ${step.id}`); -+ return record; -+}); -+const last = selected.at(-1); -+assert.equal(selected.length, 6); -+assert.deepEqual(Object.fromEntries(SOURCE_FILES.sort().map(name => [name, sha256(join(import.meta.dir, name))])), last.sourceHashes, 'Capture the final source before exporting'); -+const verification = JSON.parse(readFileSync(join(source, 'verification.json'), 'utf8')); -+for (const record of records) { -+ const result = verification.results.find(result => result.id === record.id); -+ assert(result?.sourceHashesMatch && result.patchAppliesExactly, `Verify ${record.id} before export`); -+ assert.equal(result.archiveSha256, sha256(join(source, record.id, 'source.tar.gz'))); -+ assert.equal(result.patchSha256, sha256(join(source, record.id, 'changes.patch'))); -+} -+mkdirSync(output, { recursive: true }); -+for (const record of records) cpSync(join(source, record.id), join(output, record.id), { recursive: true }); -+for (const name of ['README.md', 'REVIEW.md', 'verification.json', '01-contract-first-attempt.txt']) cpSync(join(source, name), join(output, name)); -+const report = { -+ recordedAt: last.recordedAt, scope: last.scope, checks: last.checks, -+ standalone: { version: last.packageVersion, passed: last.checks.length }, -+ milestones: selected.map((record, index) => ({ -+ ...record.history.at(-1), screenshot: `${record.id}/screen.png`, -+ build: { -+ ...guide[index], source: `${record.id}/source.tar.gz`, -+ changes: `${record.id}/changes.patch`, report: `${record.id}/run.json`, -+ ...(record.previous ? { previousSource: `${record.previous}/source.tar.gz` } : {}), -+ passed: record.checks.length, -+ }, -+ })), -+}; -+const temp = mkdtempSync(join(tmpdir(), 'commerce-verify-export-')); -+try { -+ extract(join(source, last.id, 'source.tar.gz'), temp); -+ assert.deepEqual(hashes(temp), last.sourceHashes); -+ cpSync(join(temp, 'BUILD.md'), join(output, 'build-brief.md')); -+} finally { rmSync(temp, { recursive: true, force: true }); } -+writeFileSync(join(output, 'run.json'), JSON.stringify(report, null, 2) + '\n'); -+console.log(`Exported ${selected.length} milestones and ${records.length} source revisions to ${output}`); -diff --git a/package.json b/package.json -index ca0864e..d88e6bc 100644 ---- a/package.json -+++ b/package.json -@@ -5,8 +5,9 @@ - "type": "module", - "scripts": { - "start": "bun server.mjs", -- "test": "bun verify.mjs", -- "capture": "bun capture.mjs" -+ "test": "bun verify.mjs && node --test checkpoint-tools.test.mjs", -+ "capture": "bun capture.mjs", -+ "verify:checkpoints": "bun verify-checkpoints.mjs" - }, - "dependencies": { - "openiap-commerce-protocol": "0.1.0", -diff --git a/provider.mjs b/provider.mjs -index 6e52c89..ee3d047 100644 ---- a/provider.mjs -+++ b/provider.mjs -@@ -82,6 +82,7 @@ export function createProvider(path, now) { - } - - const eventTypes = [ -+ "entitlement.granted", - "subscription.canceled", - "subscription.expired", - "entitlement.revoked", -@@ -109,6 +110,21 @@ export function createProvider(path, now) { - }, - }; - -+ function enqueue(eventType, row, occurredAt, sourceStoreEventId) { -+ const body = { -+ eventId: randomUUID(), eventType, eventVersion: COMMERCE_EVENT_VERSION, -+ occurredAt, processedAt: now(), store: FIXTURE.store, -+ environment: "local-fixture", projectId: "commerce_lab", -+ productId: row.product_id, -+ ...(row.user_id ? { userId: row.user_id } : {}), -+ subscription: snapshot(row), -+ ...(sourceStoreEventId ? { sourceStoreEventId } : {}), -+ }; -+ if (!validate("#/$defs/CommerceEvent", body)) throw new Error("Invalid event"); -+ db.query("INSERT INTO outbox (event_id, delivery_id, body) VALUES (?, ?, ?)") -+ .run(body.eventId, randomUUID(), JSON.stringify(body)); -+ } -+ - const handlers = { - providerCapabilities: () => capabilities, - verifyPurchase(input) { -@@ -138,12 +154,15 @@ export function createProvider(path, now) { - return { error: "INVALID_REQUEST" }; - return db.transaction(() => { - const key = fingerprint(input.evidence); -- db.query( -+ const updated = db.query( - "UPDATE purchases SET user_id = ? WHERE fingerprint = ? AND user_id IS NULL", - ).run(input.userId, key); - const row = db -- .query("SELECT user_id FROM purchases WHERE fingerprint = ?") -+ .query("SELECT * FROM purchases WHERE fingerprint = ?") - .get(key); -+ if (updated.changes && isEntitled(row.state, row.expires_at, now())) { -+ enqueue("entitlement.granted", row, now()); -+ } - return { bound: row?.user_id === input.userId }; - })(); - }, -@@ -210,12 +229,11 @@ export function createProvider(path, now) { - .query("SELECT * FROM purchases WHERE fingerprint = ?") - .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"); -+ } - db.query("INSERT INTO observations VALUES (?)").run(id); -- if ( -- occurredAt <= row.observed_at || -- (kind === "expire" && occurredAt < row.expires_at) -- ) -- return false; -+ if (occurredAt <= row.observed_at) return false; - if ( - (kind === "cancel" && (!row.will_renew || row.state !== "Active")) || - (kind === "expire" && row.state === "Expired") -@@ -235,27 +253,7 @@ export function createProvider(path, now) { - ]; - if (kind === "expire" && wasActive && row.user_id) - types.push("entitlement.revoked"); -- for (const eventType of types) { -- const body = { -- eventId: randomUUID(), -- eventType, -- eventVersion: COMMERCE_EVENT_VERSION, -- occurredAt, -- processedAt: now(), -- store: FIXTURE.store, -- environment: "local-fixture", -- projectId: "commerce_lab", -- productId: row.product_id, -- ...(row.user_id ? { userId: row.user_id } : {}), -- subscription: snapshot(next), -- sourceStoreEventId: id, -- }; -- if (!validate("#/$defs/CommerceEvent", body)) -- throw new Error("Invalid event"); -- db.query( -- "INSERT INTO outbox (event_id, delivery_id, body) VALUES (?, ?, ?)", -- ).run(body.eventId, randomUUID(), JSON.stringify(body)); -- } -+ for (const eventType of types) enqueue(eventType, next, occurredAt, id); - return true; - })(); - } -diff --git a/scenario.mjs b/scenario.mjs -index 2d4e3b9..b229c15 100644 ---- a/scenario.mjs -+++ b/scenario.mjs -@@ -155,6 +155,9 @@ export function createScenario(runtime) { - (await run("entitlements", { userId: FIXTURE.userId })).body.productIds, - [FIXTURE.productId], - ); -+ check("First binding queues one grant; repeat and conflict queue none", -+ runtime.provider.inspect().deliveries.map(row => row.eventType), -+ ["entitlement.granted"]); - } else if (stage === 3) { - runtime.time = FIXTURE.startsAt + 86_400_000; - runtime.provider.observe({ -@@ -173,7 +176,7 @@ export function createScenario(runtime) { - ); - check( - "Cancellation queues one event", -- runtime.provider.inspect().deliveries.length, -+ runtime.provider.inspect().deliveries.filter(row => row.eventType === "subscription.canceled").length, - 1, - ); - } else if (stage === 4) { -@@ -221,9 +224,9 @@ export function createScenario(runtime) { - ), - }); - check( -- "Redelivery has one durable inbox effect", -+ "Redelivery has one inbox row per event", - runtime.receiver.count(), -- 1, -+ 2, - ); - } else if (stage === 5) { - runtime.time = FIXTURE.expiresAt; -@@ -253,7 +256,7 @@ export function createScenario(runtime) { - check( - "Receiver deduplication survives restart", - runtime.receiver.count(), -- 1, -+ 2, - ); - responses.push({ - operation: "webhook: expiry + revocation", -@@ -264,7 +267,7 @@ export function createScenario(runtime) { - runtime.post, - ), - }); -- check("All three events reach the receiver", runtime.receiver.count(), 3); -+ check("All four events reach the receiver", runtime.receiver.count(), 4); - check( - "The final status is inactive", - (await run("subscriptionStatus", { userId: FIXTURE.userId })).body -diff --git a/verify-checkpoints.mjs b/verify-checkpoints.mjs -new file mode 100644 -index 0000000..98a6736 ---- /dev/null -+++ b/verify-checkpoints.mjs -@@ -0,0 +1,28 @@ -+import assert from 'node:assert/strict'; -+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -+import { tmpdir } from 'node:os'; -+import { join, resolve } from 'node:path'; -+import { extract, hashes, readRecords, run, sha256 } from './checkpoint-tools.mjs'; -+ -+const source = resolve(process.argv[2] ?? join(import.meta.dir, 'docs/build')); -+const temp = mkdtempSync(join(tmpdir(), 'commerce-verify-')); -+const records = readRecords(source); -+const results = []; -+try { -+ const applied = join(temp, 'applied'); -+ mkdirSync(applied); -+ for (const record of records) { -+ const directory = join(source, record.id); -+ const archive = join(directory, 'source.tar.gz'); -+ const consumer = join(temp, record.id); -+ extract(archive, consumer); -+ assert.deepEqual(hashes(consumer), record.sourceHashes, `${record.id}: archive hashes`); -+ const patch = join(directory, 'changes.patch'); -+ if (readFileSync(patch, 'utf8')) run('git', ['apply', patch], applied); -+ assert.deepEqual(hashes(applied), record.sourceHashes, `${record.id}: patch chain from empty`); -+ const commands = [run('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], consumer), run('npm', ['test'], consumer)]; -+ results.push({ id: record.id, archiveSha256: sha256(archive), patchSha256: sha256(patch), sourceHashesMatch: true, patchAppliesExactly: true, commands }); -+ console.log(`Verified ${record.id}: extracted source, patch chain, npm ci, npm test`); -+ } -+ writeFileSync(join(source, 'verification.json'), JSON.stringify({ recordedAt: new Date().toISOString(), scope: 'Each recorded archive extracted outside both repositories; patches applied in order from an empty directory; published dependencies installed with npm.', results }, null, 2) + '\n'); -+} finally { rmSync(temp, { recursive: true, force: true }); } -diff --git a/verify.mjs b/verify.mjs -index 3884882..234beb9 100644 ---- a/verify.mjs -+++ b/verify.mjs -@@ -99,7 +99,7 @@ export async function verifyLab({ compareSigner } = {}) { - "UNSUPPORTED_PROFILE", - ); - check( -- "Late cancellation cannot reopen expired access", -+ "Cancellation after expiry is ignored", - lab.runtime.provider.observe({ - id: "late-cancel", - kind: "cancel", -@@ -123,9 +123,9 @@ export async function verifyLab({ compareSigner } = {}) { - ); - check("Tampered HTTP body has no inbox effect", invalid.status, 401); - check( -- "Receiver still has exactly three events", -+ "Receiver still has exactly four events", - lab.runtime.receiver.count(), -- 3, -+ 4, - ); - check( - "Cross-origin demo mutations are refused", -@@ -154,10 +154,17 @@ export async function verifyLab({ compareSigner } = {}) { - ), - ); - check( -- "Concurrent ownership claims have one winner", -+ "Overlapping HTTP ownership claims have one winner", - bindings.filter((row) => row.body.bound).length, - 1, - ); -+ check("A cancellation older than the active row is ignored", -+ fresh.runtime.provider.observe({ id: "old-active-cancel", kind: "cancel", occurredAt: FIXTURE.startsAt - 1 }), false); -+ check("Ignoring an old cancellation preserves renewal", -+ fresh.runtime.provider.inspect().purchases[0].willRenew, 1); -+ assert.throws(() => fresh.runtime.provider.observe({ id: "early-expiry", kind: "expire", occurredAt: FIXTURE.startsAt }), /reconciliation/); -+ check("Conflicting expiry is not consumed", -+ fresh.runtime.provider.db.query("SELECT COUNT(*) AS count FROM observations WHERE id = 'early-expiry'").get().count, 0); - fresh.runtime.time += 1000; - const observation = { - id: "atomic-cancel", -@@ -197,6 +204,22 @@ export async function verifyLab({ compareSigner } = {}) { - await fresh.close(); - rmSync(fresh.directory, { recursive: true, force: true }); - } -+ const binding = startLab(); -+ try { -+ const call = (name, input) => requestOperation(binding.runtime.baseUrl, name, input); -+ const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence }; -+ await call("verifyPurchase", evidence); -+ binding.runtime.provider.db.exec("CREATE TRIGGER fail_grant BEFORE INSERT ON outbox BEGIN SELECT RAISE(ABORT, 'injected disk failure'); END;"); -+ check("Grant failure rejects binding", (await call("bindPurchase", { ...evidence, userId: FIXTURE.userId })).httpStatus, 500); -+ check("Grant failure rolls back ownership", binding.runtime.provider.inspect().purchases[0].userId, null); -+ binding.runtime.provider.db.exec("DROP TRIGGER fail_grant"); -+ binding.runtime.time = FIXTURE.expiresAt; -+ check("An expired purchase can be bound", (await call("bindPurchase", { ...evidence, userId: FIXTURE.userId })).body.bound, true); -+ check("Binding expired evidence emits no grant", binding.runtime.provider.inspect().deliveries, []); -+ } finally { -+ await binding.close(); -+ rmSync(binding.directory, { recursive: true, force: true }); -+ } - return checks; - } - diff --git a/docs/build/06-recover-reviewed/mobile.png b/docs/build/06-recover-reviewed/mobile.png deleted file mode 100644 index 7e835b9..0000000 Binary files a/docs/build/06-recover-reviewed/mobile.png and /dev/null differ diff --git a/docs/build/06-recover-reviewed/run.json b/docs/build/06-recover-reviewed/run.json deleted file mode 100644 index b9dcbfc..0000000 --- a/docs/build/06-recover-reviewed/run.json +++ /dev/null @@ -1,748 +0,0 @@ -{ - "step": 6, - "id": "06-recover-reviewed", - "title": "Expire access and restart", - "built": "Expiry, atomic grant events, recovery, and verified source archives", - "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", - "previous": "06-recover", - "startedAt": "2026-09-07T13:18:29.899Z", - "recordedAt": "2026-09-07T13:18:33.353Z", - "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", - "sourceHashes": { - ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", - "BUILD.md": "b0a72d2fd6d7e5256470d3324b947f53fce413ea79f17e9a4aed12e895b19882", - "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", - "README.md": "cb0ff1791a1845616e778c53ca75952f12cb082f676bd9049979250179f50b7e", - "ai-task.md": "d822cbd9ec729826c0006861c077fe5ecf98499c8bbf234a481e01624d956d58", - "capture.mjs": "a4b224c002f80edaeb07dd230b9ce8e2e69f069183758d16a050757f6c8ec7d4", - "checkpoint-tools.mjs": "84ec957a2897a7466af6762bfee239ee68de9d9e0444e6ec412eaf98766dcfd9", - "checkpoint-tools.test.mjs": "e9a81447122b8ad835847e05e8a3f626030071d5c792fd9d567af80fff242d71", - "checkpoint.json": "698fa4d1081652820eacd8d435836a15c40461f1931125cd5544e79cac43d60d", - "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", - "dashboard.html": "7af8dacc8023922eb7705d8160c583b385eb4c293fa1b0b38327e92aa816cf9c", - "export-docs.mjs": "2ad723f16ff48fc59fd85e8e0ecd55a84b495abac8be3e1848de2039c8e4bff6", - "package-lock.json": "7b7e6d588b495fd74020864325d8004ad797fb6b7fc563fc373f07c5c2501479", - "package.json": "f00abdc210f907cc4496caec5b08eca54c5fb1438b13836bd0405c30cbcd0e48", - "provider.mjs": "49b2cd283347b459e417a9466ffacfcdbff3224f9aff49a29331bbf31156d080", - "scenario.mjs": "ac93d8370e7b90f526eb541353eea4fc55dcffab2d44a93a975e781e022cf01d", - "server.mjs": "44fe1930e987c0aca101d0360cd3a6cb552d344c443ea62aead9cd0e607ea143", - "verify-checkpoints.mjs": "60a108bdd6db9222f218357fa539230db40755cefdae043396863f181e6eff03", - "verify.mjs": "677599681677bf80ed62a60b64818fa0da5fa9d2b2d8d6ff425d99a5ae8a6e2d", - "webhooks.mjs": "6124cf3bdb4404c2f5a712ae8e9fc8e3382a169f8aa79d0e5476a7d5773b9800" - }, - "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 is explicitly unimplemented", - "Cancellation after expiry is ignored", - "Tampered HTTP body has no inbox effect", - "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" - ], - "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": "a5252462-17de-4b79-8dda-897c32e47a1a", - "deliveryId": "38cd5214-262c-4984-b6a8-45b23bde031d", - "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": "a5252462-17de-4b79-8dda-897c32e47a1a", - "deliveryId": "38cd5214-262c-4984-b6a8-45b23bde031d", - "attempts": 0, - "status": "pending", - "eventType": "entitlement.granted" - }, - { - "eventId": "7e5e1e9a-36df-4714-874b-67eb1e66fcd1", - "deliveryId": "ddcfdd2a-ab89-46e8-9788-36b07ff39d32", - "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": "a5252462-17de-4b79-8dda-897c32e47a1a", - "deliveryId": "38cd5214-262c-4984-b6a8-45b23bde031d", - "attempts": 3, - "status": "delivered", - "eventType": "entitlement.granted" - }, - { - "eventId": "7e5e1e9a-36df-4714-874b-67eb1e66fcd1", - "deliveryId": "ddcfdd2a-ab89-46e8-9788-36b07ff39d32", - "attempts": 3, - "status": "delivered", - "eventType": "subscription.canceled" - } - ], - "inboxCount": 2, - "responses": [ - { - "operation": "webhook: receiver unavailable", - "body": [ - { - "eventId": "a5252462-17de-4b79-8dda-897c32e47a1a", - "deliveryId": "38cd5214-262c-4984-b6a8-45b23bde031d", - "httpStatus": 503, - "attempt": 1, - "status": "pending" - }, - { - "eventId": "7e5e1e9a-36df-4714-874b-67eb1e66fcd1", - "deliveryId": "ddcfdd2a-ab89-46e8-9788-36b07ff39d32", - "httpStatus": 503, - "attempt": 1, - "status": "pending" - } - ] - }, - { - "operation": "webhook: retry after restart", - "body": [ - { - "eventId": "a5252462-17de-4b79-8dda-897c32e47a1a", - "deliveryId": "38cd5214-262c-4984-b6a8-45b23bde031d", - "httpStatus": 200, - "attempt": 2, - "status": "delivered" - }, - { - "eventId": "7e5e1e9a-36df-4714-874b-67eb1e66fcd1", - "deliveryId": "ddcfdd2a-ab89-46e8-9788-36b07ff39d32", - "httpStatus": 200, - "attempt": 2, - "status": "delivered" - } - ] - }, - { - "operation": "webhook: lost-ack redelivery", - "body": [ - { - "eventId": "a5252462-17de-4b79-8dda-897c32e47a1a", - "deliveryId": "38cd5214-262c-4984-b6a8-45b23bde031d", - "httpStatus": 200, - "attempt": 3, - "status": "delivered" - }, - { - "eventId": "7e5e1e9a-36df-4714-874b-67eb1e66fcd1", - "deliveryId": "ddcfdd2a-ab89-46e8-9788-36b07ff39d32", - "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": "a5252462-17de-4b79-8dda-897c32e47a1a", - "deliveryId": "38cd5214-262c-4984-b6a8-45b23bde031d", - "attempts": 3, - "status": "delivered", - "eventType": "entitlement.granted" - }, - { - "eventId": "7e5e1e9a-36df-4714-874b-67eb1e66fcd1", - "deliveryId": "ddcfdd2a-ab89-46e8-9788-36b07ff39d32", - "attempts": 3, - "status": "delivered", - "eventType": "subscription.canceled" - }, - { - "eventId": "7d1bc592-d078-484b-8a56-22f6cbd482f6", - "deliveryId": "9171d892-93d5-4e10-bbae-4ea475423a3b", - "attempts": 1, - "status": "delivered", - "eventType": "subscription.expired" - }, - { - "eventId": "f2802ec8-8855-4888-8831-f1c07ca8b5cb", - "deliveryId": "9544fe0f-52d9-44fc-9ebe-46271a145713", - "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": "7d1bc592-d078-484b-8a56-22f6cbd482f6", - "deliveryId": "9171d892-93d5-4e10-bbae-4ea475423a3b", - "httpStatus": 200, - "attempt": 1, - "status": "delivered" - }, - { - "eventId": "f2802ec8-8855-4888-8831-f1c07ca8b5cb", - "deliveryId": "9544fe0f-52d9-44fc-9ebe-46271a145713", - "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 - } - ], - "screenshot": "screen.png", - "scope": "Reviewed source checkpoint adapted from the Commerce Lab prototype. HTTP, SQLite and signatures execute locally; store and clock are fixtures. No full profile claim." -} diff --git a/docs/build/06-recover-reviewed/screen.png b/docs/build/06-recover-reviewed/screen.png deleted file mode 100644 index de5be8a..0000000 Binary files a/docs/build/06-recover-reviewed/screen.png and /dev/null differ diff --git a/docs/build/06-recover-reviewed/source.tar.gz b/docs/build/06-recover-reviewed/source.tar.gz deleted file mode 100644 index 1064073..0000000 Binary files a/docs/build/06-recover-reviewed/source.tar.gz and /dev/null differ diff --git a/docs/build/06-recover/attempt-1.txt b/docs/build/06-recover/attempt-1.txt index bdf3683..5e7811b 100644 --- a/docs/build/06-recover/attempt-1.txt +++ b/docs/build/06-recover/attempt-1.txt @@ -1,90 +1,20 @@ { - "startedAt": "2026-09-07T12:09:47.057Z", - "command": "npm run capture", - "verifier": "verifyLab() (also used by npm test)", - "passed": 82, - "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", - "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 durable inbox effect", - "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 three 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 is explicitly unimplemented", - "Late cancellation cannot reopen expired access", - "Tampered HTTP body has no inbox effect", - "Receiver still has exactly three events", - "Cross-origin demo mutations are refused", - "Concurrent ownership claims have one winner", - "Outbox failure rolls back subscription state", - "Failed transaction leaves the observation retryable", - "Exhausted retries enter dead-letter" + "startedAt": "2026-09-16T00:54:02.471Z", + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 207ms\n" + }, + { + "command": "npm test", + "exitCode": 0, + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs\n\nCommerce Protocol Example: 138 checks passed. No store or production service contacted.\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\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [70.00ms]\n" + } ] -} \ No newline at end of file +} diff --git a/docs/build/06-recover/changes.patch b/docs/build/06-recover/changes.patch index c04042a..96a919f 100644 --- a/docs/build/06-recover/changes.patch +++ b/docs/build/06-recover/changes.patch @@ -1,43 +1,1077 @@ +diff --git a/.yarnrc.yml b/.yarnrc.yml +new file mode 100644 +index 0000000..3186f3f +--- /dev/null ++++ b/.yarnrc.yml +@@ -0,0 +1 @@ ++nodeLinker: node-modules +diff --git a/AGENTS.md b/AGENTS.md +deleted file mode 100644 +index d5632c9..0000000 +--- a/AGENTS.md ++++ /dev/null +@@ -1,22 +0,0 @@ +-# 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 7336624..b5884a0 100644 +--- a/BUILD.md ++++ b/BUILD.md +@@ -17,12 +17,16 @@ same package name. Read these files from the installed package directory + (normally `node_modules/openiap-commerce-protocol/`): + + - `SPEC.md`: normative behavior, authorization, lifecycle, and delivery rules. +-- `DESIGN.md`: architecture and reasoning. + - `generated/openapi/commerce-protocol.openapi.json`: REST request/response API. + - `generated/bindings/http-binding.json`: operations, roles, and schema pointers. + - `generated/schemas/commerce-protocol.bundle.schema.json`: offline validation. + - `conformance/` and `vectors/`: portable checks and signature fixtures. + ++For architecture, use the [implementation guide](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/README.md) ++and [whitepaper](https://openiap.dev/commerce-protocol-rationale.pdf). Package 0.1.0 ++does not include `DESIGN.md`; read that optional file only when it exists in ++your installed version. ++ + The package supplies the contract and test artifacts, not a running backend. + Implement the backend in my project. Do not require an OpenIAP or IAPKit checkout, + and do not invent request fields, response shapes, role rules, or enum values. +@@ -40,6 +44,9 @@ Build these milestones in order: + 1. **Contract:** serve capabilities and validate requests and responses against + the generated artifacts. Start with an empty persistent database. Advertise + only demonstrated support; do not claim partially implemented profiles. ++ Package 0.1.0 (protocol 1.0) requires a nonempty event list. Until an event emitter exists, ++ treat this as unfinished scaffolding, not a provider ready for integration. ++ Core discovery cannot use `UNSUPPORTED_PROFILE` as a valid fallback. + 2. **Verification:** accept known fixture evidence, reject invalid evidence, and + distinguish an upstream outage from a negative verdict. Persist a purchase + without binding a user or granting account access. +@@ -84,4 +91,12 @@ also run real store sandbox, recovery, isolation, and load tests. Treat IAPKit a + an implementation example, never as a replacement for the protocol's contract. + + Recorded local example and reproduction instructions: +-https://github.com/hyodotdev/openiap/tree/main/packages/kit/examples/commerce-protocol ++https://github.com/hyodotdev/openiap-commerce-protocol-example ++ ++## Review each visible result ++ ++Inspect the running UI and the actual response and storage changes after every ++milestone. Keep a short record of the issue, the code or design correction, and ++the result of repeating the same check. Keep failed attempts as evidence. Do ++not invent failures for the story or mark a milestone complete from a screenshot ++alone. Save each runnable source checkpoint before adding the next feature. +diff --git a/INTEGRATE.md b/INTEGRATE.md +index 122ee9f..c497c13 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, Amazon, and Horizon OpenIAP purchase fields into the ++- `npm run demo:bridge`: maps Apple/Google 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,13 +36,6 @@ 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 +@@ -62,7 +55,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, Amazon, and Horizon ++ authenticated backend. Use `client-bridge.mjs` there to map Apple/Google + 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,49 +76,9 @@ 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. 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. ++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. + + ## Deliver and prove the connection + +diff --git a/README.md b/README.md +index d643942..ec1ec9a 100644 +--- a/README.md ++++ b/README.md +@@ -1,51 +1,123 @@ + # OpenIAP Commerce Protocol example + +-Build a small purchase-to-access backend with AI, one working milestone at a +-time. Each checkpoint includes the task, source changes, executed checks, and +-a screenshot of that version running. ++A runnable purchase-to-access backend, built and reviewed with AI in six ++milestones. Follow a purchase through verification, ownership, access, and ++signed event delivery. Inspect the actual HTTP responses and database changes. + +-## Run ++The backend uses the published **`openiap-commerce-protocol`** package. HTTP, ++SQLite, and webhook signatures run locally; the store, users, and clock are ++fictional fixtures. This is a learning example, not a production provider. + +-Use your favorite package manager to install dependencies: ++## Quick start ++ ++Install [Bun](https://bun.sh/docs/installation) for the HTTP and SQLite runtime ++(tested with Bun 1.3.13). Use your favorite package manager for dependencies and ++scripts; npm is shown here: + + ```sh ++git clone https://github.com/hyodotdev/openiap-commerce-protocol-example.git ++cd openiap-commerce-protocol-example + npm install # or pnpm install, yarn install, bun install + npm test + npm start + ``` + +-This example uses the Bun runtime for HTTP and SQLite. Its scripts work through +-any package manager with Bun installed. The protocol can be implemented in your +-own language and stack. Open http://127.0.0.1:5181 and run each available step. ++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, ++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. ++ ++![The completed local backend: expired access, delivered events, and inspectable responses](https://raw.githubusercontent.com/hyodotdev/openiap-commerce-protocol-example/main/docs/build/06-recover-reviewed-8/screen.png) ++ ++The screenshot comes from an executed source checkpoint. See its ++[run report](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/06-recover-reviewed-8/run.json) ++and the [complete build history](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/README.md). ++ ++## What you will see ++ ++| Step | What changes | Result to check | ++| ----------- | ------------------------------------------------ | ---------------------------------------------------- | ++| 1. Contract | Load schemas; start with empty storage | No purchases or access yet | ++| 2. Verify | Validate fixture evidence; save a purchase | Verification alone grants no access | ++| 3. Bind | Attach the purchase to the backend-selected user | Alice gains Premium; Bob cannot claim it | ++| 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 | ++ ++Restarting `npm start` creates a fresh temporary database, so you can replay the ++walkthrough. Step 6 reopens the existing databases **inside the running process**; ++it does not simulate an OS crash or a new process recovering external secrets. ++If port 5181 is occupied, run `COMMERCE_LAB_PORT=5183 npm start`. ++ ++## Use the part you need ++ ++| Your role | Start here | What this example provides | ++| -------------------- | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ++| Paywall / experience | [Integration brief](INTEGRATE.md) | Host purchase/result boundaries; no paywall UI implementation | ++| Commerce provider | [AI build brief](BUILD.md) | Fixture verifier, ownership/access rules, REST, SQLite, and delivery | ++| 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 ++verification schema **on the app backend**. Run `npm run demo:bridge` to check it. ++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. ++ ++## Receive events ++ ++```sh ++npm run demo:consumer ++``` ++ ++This standalone check sends fictional purchase, renewal, cancellation, expiry, ++refund, and entitlement events over HTTP. It repeats deliveries, rejects ++changed signatures, and reopens the SQLite inbox. It needs no provider server. ++ ++For a persistent receiver, set `COMMERCE_WEBHOOK_SECRET` and run ++`npm run consumer`. The endpoint is `http://127.0.0.1:5182/webhooks/commerce`. ++See the [receiver guide](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/receiver.md) ++for HTTPS proxy setup, database location, processing responsibilities, and ++expected results. Event ingestion does not calculate revenue or grant access. ++ ++## Build with AI, then check the result + +-## Build with AI ++Give [BUILD.md](BUILD.md) to your AI for a backend, or [INTEGRATE.md](INTEGRATE.md) ++for an existing product. Ask it to implement one milestone, run it, inspect the ++screen and responses, fix a failure, and repeat that same check. + +-Give [BUILD.md](BUILD.md) to your AI. Ask it to add one milestone, run it, inspect +-the actual screen and responses, fix any issue, and repeat the failed check. +-Review the result before moving to the next milestone. ++The [build record](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/README.md) ++contains independent source archives, patches, screenshots, and execution ++reports. The [review log](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/REVIEW.md) ++keeps the observed mistakes and corrections, including unfinished discovery in ++the early snapshots. This implementation grew from an earlier internal ++prototype; it was not generated from a blank project in one prompt. Task briefs ++are implementation notes, not model transcripts or editor recordings. + +-[docs/build](docs/build) records this development run. Every source archive is +-an independently runnable checkpoint. Later functionality is absent from the +-earlier source, rather than hidden behind a runtime step switch. The dashboard +-replays the operations implemented at that checkpoint. ++## Verify or record a change + +-This implementation extracts and extends the previously tested OpenIAP Commerce +-Lab prototype. It is an incremental implementation record, not a claim that an +-AI produced the backend in one prompt without reference code. ++| Command | Checks | ++| ---------------------------- | ------------------------------------------------------------------------- | ++| `npm test` | Current backend flow, failure cases, request mapper, and receiver | ++| `npm run test:tooling` | Capture and patch tooling | ++| `npm run verify:checkpoints` | Every archive, its exact patch chain, and an independent npm install/test | ++| `npm run capture` | A new immutable source checkpoint and actual desktop/mobile screenshots | + +-## Scope ++Backend commands require Bun. Recording also requires Node.js/npm, Git, tar, ++and Google Chrome. The [recording guide](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/recording.md) ++explains how to preserve a checkpoint and export evidence. GitHub CI runs the ++runtime, tooling, archive, and documentation-export checks. + +-The HTTP requests, SQLite writes, ownership rules, HMAC signatures, delivery +-retries, and database recovery are real local operations. The store, user, and +-clock are fixtures. No real purchases or credentials are needed. ++## What remains for production + +-This is an educational part of the IAPKit architecture, not a production +-provider. Real store validation, login, erasure, tenant isolation, GraphQL, +-public HTTPS delivery, and full profile conformance remain separate work. ++Real store validation and sandbox purchases, login, user erasure, 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. + +-## Record another checkpoint ++Implement every obligation of your chosen profiles from the installed ++`SPEC.md`; add store sandbox, isolation, deployment, and recovery tests. ++IAPKit is a reference implementation, not a substitute for those checks. + +-Update `ai-task.md` with the task and review notes. Run `npm test`, inspect the +-dashboard, then `npm run capture` with Google Chrome installed. Capture runs the +-checks again and preserves failed attempts. Published checkpoints are immutable; +-use a new directory name in `checkpoint.json` for another revision. ++MIT licensed. See [LICENSE](LICENSE). diff --git a/ai-task.md b/ai-task.md -index 50efd02..1c910ca 100644 +index 50efd02..7a2e80f 100644 --- a/ai-task.md +++ b/ai-task.md -@@ -1,6 +1,6 @@ +@@ -1,7 +1,63 @@ -# Deliver, retry, deduplicate -+# Expire access and restart ++# Review the completed backend -Add an HMAC receiver, durable inbox, and bounded retry worker. Exercise 503, reopen the database, retry successfully, then simulate a lost acknowledgement. Redelivery must have one inbox effect. -+Add expiry observations and recovery checks. Recheck access at the exact deadline before a notification arrives. Prove duplicate and late observations cannot reopen access. Review the full implementation and repeat failing checks after every correction. ++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. + +-Use the existing reviewed prototype where it satisfies the installed contract. +-Run the backend, inspect the result, correct problems, and rerun the affected check. +-Keep all work uncommitted. ++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. +diff --git a/capture.mjs b/capture.mjs +index dd54545..d8f85e1 100644 +--- a/capture.mjs ++++ b/capture.mjs +@@ -1,94 +1,173 @@ +-import assert from 'node:assert/strict'; +-import { createHash } from 'node:crypto'; +-import { spawnSync } from 'node:child_process'; +-import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, cpSync, rmSync } from 'node:fs'; +-import { join } from 'node:path'; +-import { chromium } from '@playwright/test'; +-import { startLab } from './server.mjs'; +-import { verifyLab } from './verify.mjs'; +-import { STAGES } from './scenario.mjs'; ++import assert from "node:assert/strict"; ++import { ++ existsSync, ++ mkdirSync, ++ mkdtempSync, ++ readFileSync, ++ writeFileSync, ++ cpSync, ++ rmSync, ++} from "node:fs"; ++import { tmpdir } from "node:os"; ++import { join } from "node:path"; ++import { pathToFileURL } from "node:url"; ++import { chromium } from "@playwright/test"; ++import { ++ SOURCE_FILES, ++ createPatch, ++ extract, ++ hashes, ++ run, ++ sanitize, ++ sha256, ++ nextAttempt, ++} from "./checkpoint-tools.mjs"; - Use the existing reviewed prototype where it satisfies the installed contract. - Run the backend, inspect the result, correct problems, and rerun the affected check. + const root = import.meta.dir; +-const checkpoint = JSON.parse(readFileSync(join(root, 'checkpoint.json'), 'utf8')); +-const output = join(root, 'docs/build', checkpoint.id); ++const checkpoint = JSON.parse( ++ readFileSync(join(root, "checkpoint.json"), "utf8"), ++); ++assert( ++ /^\d\d-[\w-]+$/.test(checkpoint.id), ++ "Use a simple checkpoint directory name", ++); ++const output = join(root, "docs/build", checkpoint.id); + mkdirSync(output, { recursive: true }); +-assert(!existsSync(join(output, 'run.json')), 'Checkpoint already published; choose a new checkpoint id.'); +-const attempt = readdirSync(output).filter(name => name.startsWith('attempt-')).length + 1; ++assert( ++ !existsSync(join(output, "run.json")), ++ "Checkpoint already published; choose a new checkpoint id.", ++); ++const attempt = nextAttempt(output); ++const temp = mkdtempSync(join(tmpdir(), "commerce-capture-")); ++const current = join(temp, "after"), ++ previous = join(temp, "before"); + const startedAt = new Date().toISOString(); +-let checks; ++let lab, browser; + try { +- checks = await verifyLab(); +- writeFileSync(join(output, `attempt-${attempt}.txt`), JSON.stringify({ startedAt, command: 'npm run capture', verifier: 'verifyLab() (also used by npm test)', passed: checks.length, checks }, null, 2)); +-} catch (error) { +- writeFileSync(join(output, `attempt-${attempt}.txt`), `${startedAt}\n${error.stack}\n`); +- throw error; +-} +- +-const files = readdirSync(root).filter(name => /\.(mjs|html|json|md)$/.test(name) || name === 'LICENSE' || name === '.gitignore'); +-const cache = join(root, '.build-cache'); +-const current = join(cache, 'after'); +-const previous = join(cache, 'before'); +-mkdirSync(previous, { recursive: true }); +-rmSync(current, { recursive: true, force: true }); +-mkdirSync(current, { recursive: true }); +-for (const name of files) cpSync(join(root, name), join(current, name)); +-const diff = spawnSync('git', ['diff', '--no-index', '--no-ext-diff', '--', 'before', 'after'], { cwd: cache, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }); +-assert([0, 1].includes(diff.status), diff.stderr); +-const patch = diff.stdout.split('\n').map(line => /^(diff --git |--- a\/|\+\+\+ b\/)/.test(line) ? line.replace(/([ab])\/(?:before|after)\//g, '$1/') : line).join('\n'); +-writeFileSync(join(output, 'changes.patch'), patch); +-const archive = spawnSync('tar', ['-czf', join(output, 'source.tar.gz'), '-C', current, ...files]); +-assert.equal(archive.status, 0, archive.stderr?.toString()); +- +-const lab = startLab(); +-const browser = await chromium.launch({ channel: 'chrome' }); +-try { +- const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } }); ++ mkdirSync(current); ++ mkdirSync(previous); ++ for (const name of SOURCE_FILES) ++ cpSync(join(root, name), join(current, name)); ++ const sourceHashes = hashes(current); ++ if (checkpoint.previous) { ++ assert(/^\d\d-[\w-]+$/.test(checkpoint.previous)); ++ const prior = join(root, "docs/build", checkpoint.previous); ++ extract(join(prior, "source.tar.gz"), previous); ++ assert.deepEqual( ++ hashes(previous), ++ JSON.parse(readFileSync(join(prior, "run.json"), "utf8")).sourceHashes, ++ ); ++ } ++ writeFileSync(join(output, "changes.patch"), createPatch(previous, current)); ++ run("env", [ ++ "COPYFILE_DISABLE=1", ++ "tar", ++ "-czf", ++ join(output, "source.tar.gz"), ++ "-C", ++ current, ++ ...SOURCE_FILES, ++ ]); ++ const commands = [ ++ run("npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], current), ++ run("npm", ["test"], current), ++ run("npm", ["run", "test:tooling"], current), ++ ]; ++ writeFileSync( ++ join(output, `attempt-${attempt}.txt`), ++ JSON.stringify({ startedAt, commands }, null, 2) + "\n", ++ ); ++ const { startLab } = await import( ++ pathToFileURL(join(current, "server.mjs")).href ++ ); ++ const { verifyLab } = await import( ++ pathToFileURL(join(current, "verify.mjs")).href ++ ); ++ const { STAGES } = await import( ++ pathToFileURL(join(current, "scenario.mjs")).href ++ ); ++ const checks = await verifyLab(); ++ lab = startLab(); ++ browser = await chromium.launch({ channel: "chrome" }); ++ const page = await browser.newPage({ ++ viewport: { width: 1280, height: 1000 }, ++ }); + const errors = []; +- page.on('pageerror', error => errors.push(error.message)); +- await page.goto(lab.runtime.baseUrl, { waitUntil: 'domcontentloaded' }); ++ page.on("pageerror", (error) => errors.push(error.message)); ++ await page.goto(lab.runtime.baseUrl, { waitUntil: "domcontentloaded" }); + for (let step = 1; step <= STAGES.length; step++) { +- await page.getByRole('button', { name: `Run step ${step} →`, exact: true }).click(); +- await page.locator('#progress').filter({ hasText: `Milestone ${step} / ${STAGES.length}` }).waitFor(); ++ await page ++ .getByRole("button", { name: `Run step ${step} →`, exact: true }) ++ .click(); ++ await page ++ .locator("#progress") ++ .filter({ hasText: `Milestone ${step} / ${STAGES.length}` }) ++ .waitFor(); + } +- assert.deepEqual(errors, []); +- assert.equal(await page.locator('#error').textContent(), ''); +- assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); +- const responses = page.locator('#responses details'); +- if (await responses.count()) { +- const wasOpen = (await responses.last().getAttribute('open')) !== null; +- if (wasOpen) await responses.last().locator('summary').click(); +- assert.equal(await responses.last().getAttribute('open'), null); +- await responses.last().locator('summary').click(); +- assert.equal(await responses.last().getAttribute('open'), ''); +- assert(await responses.last().locator('pre').isVisible()); +- if (!wasOpen) await responses.last().locator('summary').click(); ++ for (const [filename, viewport] of [ ++ ["screen.png", { width: 1280, height: 1000 }], ++ ["mobile.png", { width: 390, height: 844 }], ++ ]) { ++ await page.setViewportSize(viewport); ++ assert.equal(await page.locator("#error").textContent(), ""); ++ assert.equal( ++ await page.evaluate( ++ () => document.documentElement.scrollWidth > innerWidth, ++ ), ++ false, ++ ); ++ const responses = page.locator("#responses details"); ++ if (await responses.count()) { ++ const wasOpen = (await responses.last().getAttribute("open")) !== null; ++ if (wasOpen) await responses.last().locator("summary").click(); ++ assert.equal(await responses.last().getAttribute("open"), null); ++ await responses.last().locator("summary").click(); ++ assert(await responses.last().locator("pre").isVisible()); ++ if (!wasOpen) await responses.last().locator("summary").click(); ++ } ++ await page.screenshot({ path: join(output, filename), fullPage: true }); + } +- await page.screenshot({ path: join(output, 'screen.png'), fullPage: true }); +- await page.setViewportSize({ width: 390, height: 844 }); +- assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); +- await page.screenshot({ path: join(output, 'mobile.png'), fullPage: true }); +- const history = lab.scenario.history; ++ assert.deepEqual(errors, []); ++ for (const name of SOURCE_FILES) ++ assert.equal( ++ sha256(join(current, name)), ++ sourceHashes[name], ++ "Captured source changed during verification", ++ ); + const record = { + ...checkpoint, + startedAt, + recordedAt: new Date().toISOString(), +- packageVersion: JSON.parse(readFileSync(join(root, 'node_modules/@hyodotdev/openiap-commerce-protocol/package.json'), 'utf8')).version, +- task: readFileSync(join(root, 'ai-task.md'), 'utf8'), +- sourceHashes: Object.fromEntries(files.map(name => [name, createHash('sha256').update(readFileSync(join(current, name))).digest('hex')])), ++ packageVersion: JSON.parse( ++ readFileSync( ++ join(current, "node_modules/@hyodotdev/openiap-commerce-protocol/package.json"), ++ "utf8", ++ ), ++ ).version, ++ task: readFileSync(join(current, "ai-task.md"), "utf8"), ++ sourceHashes, + checks, +- history, +- screenshot: 'screen.png', +- scope: 'Incremental source checkpoint adapted from the reviewed Commerce Lab prototype. HTTP and SQLite execute locally; store and clock are fixtures. No full profile claim.', ++ history: lab.scenario.history, ++ 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.", + }; +- writeFileSync(join(output, 'run.json'), JSON.stringify(record, null, 2) + '\n'); +- rmSync(previous, { recursive: true, force: true }); +- cpSync(current, previous, { recursive: true }); +- console.log(`Captured ${checkpoint.id}: ${checks.length} checks, ${files.length} source files.`); ++ writeFileSync( ++ join(output, "run.json"), ++ JSON.stringify(record, null, 2) + "\n", ++ ); ++ console.log( ++ `Captured ${checkpoint.id}: ${checks.length} checks, ${SOURCE_FILES.length} source files.`, ++ ); + } catch (error) { +- writeFileSync(join(output, `browser-failure-${attempt}.txt`), `${new Date().toISOString()}\n${error.stack}\n`); ++ writeFileSync( ++ join(output, `failure-${attempt}.txt`), ++ `${new Date().toISOString()}\n${sanitize(String(error?.stack ?? error))}\n`, ++ ); + throw error; + } finally { +- await browser.close(); +- await lab.close(); +- rmSync(lab.directory, { recursive: true, force: true }); ++ await browser?.close(); ++ await lab?.close(); ++ rmSync(temp, { recursive: true, force: true }); ++ if (lab) rmSync(lab.directory, { recursive: true, force: true }); + } +diff --git a/checkpoint-tools.mjs b/checkpoint-tools.mjs +index 8ef96bc..06b5f3b 100644 +--- a/checkpoint-tools.mjs ++++ b/checkpoint-tools.mjs +@@ -8,7 +8,6 @@ import { + mkdtempSync, + readFileSync, + readdirSync, +- statSync, + rmSync, + writeFileSync, + } from "node:fs"; +@@ -19,7 +18,6 @@ export const SOURCE_FILES = [ + ".gitignore", + ".yarnrc.yml", + "LICENSE", +- "AGENTS.md", + "README.md", + "BUILD.md", + "INTEGRATE.md", +@@ -29,9 +27,6 @@ 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", +@@ -39,16 +34,6 @@ 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", +@@ -57,18 +42,12 @@ export const SOURCE_FILES = [ + ]; + export const sha256 = (file) => + createHash("sha256").update(readFileSync(file)).digest("hex"); +-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 const hashes = (directory) => ++ Object.fromEntries( ++ readdirSync(directory) ++ .sort() ++ .map((name) => [name, sha256(join(directory, name))]), ++ ); + export function sanitize(text) { + const roots = [process.cwd(), import.meta.dir, homedir()] + .filter(Boolean) +@@ -154,7 +133,7 @@ export function createPatch(before, after) { + } + } + export function readRecords(directory) { +- const records = readdirSync(directory) ++ return readdirSync(directory) + .filter( + (name) => + /^\d\d-[\w-]+$/.test(name) && +@@ -164,25 +143,4 @@ 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 a45e857..e807fae 100644 +--- a/checkpoint-tools.test.mjs ++++ b/checkpoint-tools.test.mjs +@@ -108,29 +108,3 @@ 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 0f3893c..36c56ba 100644 +index 010fb78..9697fda 100644 --- a/checkpoint.json +++ b/checkpoint.json -@@ -1,7 +1,7 @@ +@@ -1,8 +1,8 @@ { - "step": 5, - "id": "05-deliver", - "title": "Deliver, retry, deduplicate", - "built": "HMAC signatures + retry worker + durable receiver inbox", -- "result": "A 503 retries successfully. Redelivery creates no second inbox row." +- "result": "A 503 retries successfully. Redelivery creates no second inbox row.", +- "previous": "04-cancel" + "step": 6, + "id": "06-recover", + "title": "Expire access and restart", -+ "built": "Expiry-aware reads + recovery from SQLite", -+ "result": "Access closes at the deadline. Restarting preserves purchases and deliveries." ++ "built": "Exact-byte webhook authentication with Unicode regression checks", ++ "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", ++ "previous": "05-deliver" + } +diff --git a/client-bridge.mjs b/client-bridge.mjs +index 248d314..1d5d64a 100644 +--- a/client-bridge.mjs ++++ b/client-bridge.mjs +@@ -2,43 +2,18 @@ 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, context = {}) { ++export function toVerifyPurchaseInput(purchase) { + const { store, purchaseToken } = purchase ?? {}; +- let input; +- const required = (value) => { +- if (typeof value !== "string" || !value.trim()) +- throw new Error("Purchase evidence is required"); +- return value; ++ 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 } }), + }; +- 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; +@@ -64,50 +39,15 @@ 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 requires its authenticated store user", ++ "Amazon needs its own adapter", + { store: "amazon", purchaseToken: "fictional" }, + ], + [ +- "Horizon requires its authenticated store user", ++ "Horizon needs its own adapter", + { store: "horizon", purchaseToken: "fictional" }, + ], + ["missing evidence", { store: "apple" }], +diff --git a/consumer.mjs b/consumer.mjs +index 72eb7cb..37a5bec 100644 +--- a/consumer.mjs ++++ b/consumer.mjs +@@ -7,7 +7,7 @@ import { WEBHOOK, COMMERCE_EVENT_VERSION } from "@hyodotdev/openiap-commerce-pro + import { createReceiver, sign } from "./webhooks.mjs"; + import { validate } from "./contract.mjs"; + +-// One app inbox; named emitters can bind separate signing keys to project IDs. ++// One configured emitter/project and signing key per receiver database. + 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,8 +27,6 @@ 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/erasure.mjs b/erasure.mjs +deleted file mode 100644 +index fb977b1..0000000 +--- a/erasure.mjs ++++ /dev/null +@@ -1,36 +0,0 @@ +-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 4dbddd0..f7da37e 100644 +--- a/export-docs.mjs ++++ b/export-docs.mjs +@@ -29,15 +29,8 @@ const selected = guide.map((step) => { + return record; + }); + const last = selected.at(-1); +-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(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( + Object.fromEntries( + SOURCE_FILES.sort().map((name) => [ +diff --git a/package-lock.json b/package-lock.json +index c5800f3..78fad4d 100644 +--- a/package-lock.json ++++ b/package-lock.json +@@ -7,6 +7,7 @@ + "": { + "name": "openiap-commerce-protocol-example", + "version": "0.0.0", ++ "license": "MIT", + "dependencies": { + "@hyodotdev/openiap-commerce-protocol": "0.3.0", + "ajv": "^8.17.1" +diff --git a/package.json b/package.json +index 79f3dea..e52dc85 100644 +--- a/package.json ++++ b/package.json +@@ -6,7 +6,12 @@ + "scripts": { + "start": "bun server.mjs", + "test": "bun verify.mjs", +- "capture": "bun capture.mjs" ++ "capture": "bun capture.mjs", ++ "verify:checkpoints": "bun verify-checkpoints.mjs", ++ "test:tooling": "bun test checkpoint-tools.test.mjs", ++ "consumer": "bun consumer.mjs", ++ "demo:consumer": "bun consumer.mjs demo", ++ "demo:bridge": "bun client-bridge.mjs" + }, + "dependencies": { + "@hyodotdev/openiap-commerce-protocol": "0.3.0", +@@ -14,5 +19,11 @@ + }, + "devDependencies": { + "@playwright/test": "1.62.1" ++ }, ++ "description": "A runnable, documented Commerce Protocol backend with AI build checkpoints.", ++ "license": "MIT", ++ "repository": { ++ "type": "git", ++ "url": "https://github.com/hyodotdev/openiap-commerce-protocol-example.git" + } } diff --git a/provider.mjs b/provider.mjs -index 1e43f78..6e52c89 100644 +index b8028f2..cf3c935 100644 --- a/provider.mjs +++ b/provider.mjs -@@ -81,11 +81,15 @@ export function createProvider(path, now) { +@@ -45,7 +45,7 @@ export function createProvider(path, now) { + CREATE TABLE IF NOT EXISTS purchases ( + fingerprint TEXT PRIMARY KEY, user_id TEXT, product_id TEXT NOT NULL, + state TEXT NOT NULL, expires_at INTEGER NOT NULL, will_renew INTEGER NOT NULL, +- observed_at INTEGER NOT NULL ++ observed_at INTEGER NOT NULL, entitlement_granted INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS observations (id TEXT PRIMARY KEY); + CREATE TABLE IF NOT EXISTS outbox ( +@@ -81,14 +81,27 @@ export function createProvider(path, now) { }; } - const eventTypes = ["subscription.canceled"]; + const eventTypes = [ ++ "entitlement.granted", + "subscription.canceled", + "subscription.expired", + "entitlement.revoked", @@ -46,11 +1080,101 @@ index 1e43f78..6e52c89 100644 providerCapabilitiesSchema.$defs.StoreCapabilities.properties, ); - const supported = new Set(["initialValidation", "subscriptions", "entitlements", "serverNotifications"]); -+ const supported = new Set(["initialValidation", "subscriptions", "entitlements", "serverNotifications", "expiration"]); ++ const supported = new Set([ ++ "initialValidation", ++ "subscriptions", ++ "entitlements", ++ "serverNotifications", ++ "expiration", ++ ]); const capabilities = { - specVersion: HTTP_BINDING.protocolVersion, - implementation: { name: "Commerce Protocol Example — fictional fixture store" }, -@@ -193,7 +197,7 @@ export function createProvider(path, now) { + commerceProtocolVersion: HTTP_BINDING.protocolVersion, +- implementation: { name: "Commerce Protocol Example — fictional fixture store" }, ++ implementation: { ++ name: "Commerce Protocol Example — fictional fixture store", ++ }, + eventTypes, + stores: { + fixture: Object.fromEntries( +@@ -105,6 +118,28 @@ export function createProvider(path, now) { + }, + }; + ++ function enqueue(eventType, row, occurredAt, sourceStoreEventId) { ++ const body = { ++ eventId: randomUUID(), ++ eventType, ++ eventVersion: COMMERCE_EVENT_VERSION, ++ occurredAt, ++ processedAt: now(), ++ store: FIXTURE.store, ++ environment: "local-fixture", ++ projectId: "commerce_example", ++ productId: row.product_id, ++ ...(row.user_id ? { userId: row.user_id } : {}), ++ subscription: snapshot(row), ++ ...(sourceStoreEventId ? { sourceStoreEventId } : {}), ++ }; ++ if (!validate("#/$defs/CommerceEvent", body)) ++ throw new Error("Invalid event"); ++ db.query( ++ "INSERT INTO outbox (event_id, delivery_id, body) VALUES (?, ?, ?)", ++ ).run(body.eventId, randomUUID(), JSON.stringify(body)); ++ } ++ + const handlers = { + providerCapabilities: () => capabilities, + verifyPurchase(input) { +@@ -112,7 +147,7 @@ export function createProvider(path, now) { + if (verdict.error) return verdict; + if (verdict.accepted) { + db.query( +- `INSERT OR IGNORE INTO purchases VALUES (?, NULL, ?, 'Active', ?, 1, ?)`, ++ `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, +@@ -122,8 +157,12 @@ export function createProvider(path, now) { + } + return { + store: FIXTURE.store, +- isValid: verdict.accepted, +- state: verdict.accepted ? "ENTITLED" : "INAUTHENTIC", ++ isValid: verdict.accepted && now() < FIXTURE.expiresAt, ++ state: !verdict.accepted ++ ? "INAUTHENTIC" ++ : now() >= FIXTURE.expiresAt ++ ? "EXPIRED" ++ : "ENTITLED", + ...(verdict.accepted ? { productId: FIXTURE.productId } : {}), + environment: "local-fixture", + }; +@@ -134,12 +173,20 @@ export function createProvider(path, now) { + return { error: "INVALID_REQUEST" }; + return db.transaction(() => { + const key = fingerprint(input.evidence); +- db.query( +- "UPDATE purchases SET user_id = ? WHERE fingerprint = ? AND user_id IS NULL", +- ).run(input.userId, key); ++ const updated = db ++ .query( ++ "UPDATE purchases SET user_id = ? WHERE fingerprint = ? AND user_id IS NULL", ++ ) ++ .run(input.userId, key); + const row = db +- .query("SELECT user_id FROM purchases WHERE fingerprint = ?") ++ .query("SELECT * FROM purchases WHERE fingerprint = ?") + .get(key); ++ if (updated.changes && isEntitled(row.state, row.expires_at, now())) { ++ enqueue("entitlement.granted", row, row.observed_at); ++ db.query( ++ "UPDATE purchases SET entitlement_granted = 1 WHERE fingerprint = ?", ++ ).run(key); ++ } + return { bound: row?.user_id === input.userId }; + })(); + }, +@@ -193,7 +240,7 @@ export function createProvider(path, now) { function observe({ id, kind, occurredAt }) { if ( @@ -59,23 +1183,23 @@ index 1e43f78..6e52c89 100644 !Number.isSafeInteger(occurredAt) || occurredAt > now() ) { -@@ -207,21 +211,30 @@ export function createProvider(path, now) { +@@ -206,43 +253,37 @@ export function createProvider(path, now) { + .query("SELECT * FROM purchases WHERE fingerprint = ?") .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"); ++ } db.query("INSERT INTO observations VALUES (?)").run(id); - if (occurredAt <= row.observed_at) -+ if ( -+ occurredAt <= row.observed_at || -+ (kind === "expire" && occurredAt < row.expires_at) -+ ) - return false; +- return false; ++ if (occurredAt < row.observed_at) return false; if ( - (kind === "cancel" && (!row.will_renew || row.state !== "Active")) + (kind === "cancel" && (!row.will_renew || row.state !== "Active")) || + (kind === "expire" && row.state === "Expired") ) return false; -+ const wasActive = isEntitled(row.state, row.expires_at, occurredAt - 1); const next = { ...row, will_renew: 0, @@ -86,34 +1210,163 @@ index 1e43f78..6e52c89 100644 "UPDATE purchases SET state = ?, will_renew = 0, observed_at = ? WHERE fingerprint = ?", ).run(next.state, occurredAt, row.fingerprint); - const types = ["subscription.canceled"]; +- for (const eventType of types) { +- const body = { +- eventId: randomUUID(), +- eventType, +- eventVersion: COMMERCE_EVENT_VERSION, +- occurredAt, +- processedAt: now(), +- store: FIXTURE.store, +- environment: "local-fixture", +- projectId: "commerce_lab", +- productId: row.product_id, +- ...(row.user_id ? { userId: row.user_id } : {}), +- subscription: snapshot(next), +- sourceStoreEventId: id, +- }; +- if (!validate("#/$defs/CommerceEvent", body)) +- throw new Error("Invalid event"); + const types = [ + kind === "cancel" ? "subscription.canceled" : "subscription.expired", + ]; -+ if (kind === "expire" && wasActive && row.user_id) ++ if ( ++ row.entitlement_granted && ++ !isEntitled(next.state, next.expires_at, now()) ++ ) { + types.push("entitlement.revoked"); - for (const eventType of types) { - const body = { - eventId: randomUUID(), + db.query( +- "INSERT INTO outbox (event_id, delivery_id, body) VALUES (?, ?, ?)", +- ).run(body.eventId, randomUUID(), JSON.stringify(body)); ++ "UPDATE purchases SET entitlement_granted = 0 WHERE fingerprint = ?", ++ ).run(row.fingerprint); + } ++ for (const eventType of types) enqueue(eventType, next, occurredAt, id); + return true; + })(); + } diff --git a/scenario.mjs b/scenario.mjs -index 366fb01..2d4e3b9 100644 +index 366fb01..525327c 100644 --- a/scenario.mjs +++ b/scenario.mjs -@@ -28,6 +28,11 @@ export const STAGES = [ - "title": "Deliver, retry, deduplicate", - "built": "HMAC signatures + retry worker + durable receiver inbox", - "result": "A 503 retries successfully. Redelivery creates no second inbox row." +@@ -5,30 +5,37 @@ import { deliver } from "./webhooks.mjs"; + + export const STAGES = [ + { +- "title": "Start with the contract", +- "built": "HTTP routes + schema validation + SQLite", +- "result": "A running server, an empty purchase table, and no access." ++ title: "Start with the contract", ++ built: "HTTP routes + schema validation + SQLite", ++ result: "A running server, an empty purchase table, and no access.", + }, + { +- "title": "Verify a purchase", +- "built": "Fixture store adapter + purchase persistence", +- "result": "Valid evidence is saved. Alice still has no access." ++ title: "Verify a purchase", ++ built: "Fixture store adapter + purchase persistence", ++ result: "Valid evidence is saved. Alice still has no access.", + }, + { +- "title": "Connect it to a user", +- "built": "Server authorization + atomic binding + entitlement reads", +- "result": "Alice gets Premium. Another user cannot take the purchase." ++ title: "Connect it to a user", ++ built: "Server authorization + atomic binding + entitlement reads", ++ result: "Alice gets Premium. Another user cannot take the purchase.", + }, + { +- "title": "Handle cancellation", +- "built": "Lifecycle processing + transactional event outbox", +- "result": "Renewal stops. Alice keeps the time she already paid for." ++ title: "Handle cancellation", ++ built: "Lifecycle processing + transactional event outbox", ++ result: "Renewal stops. Alice keeps the time she already paid for.", + }, + { +- "title": "Deliver, retry, deduplicate", +- "built": "HMAC signatures + retry worker + durable receiver inbox", +- "result": "A 503 retries successfully. Redelivery creates no second inbox row." +- } ++ title: "Deliver, retry, deduplicate", ++ built: "HMAC signatures + retry worker + durable receiver inbox", ++ result: ++ "A 503 retries successfully. Redelivery creates no second inbox row.", + }, + { -+ "title": "Expire access and restart", -+ "built": "Expiry-aware reads + recovery from SQLite", -+ "result": "Access closes at the deadline. Restarting preserves purchases and deliveries." - } ++ title: "Expire access and restart", ++ built: "Expiry-aware reads + recovery from SQLite", ++ result: ++ "Access closes at the deadline. Restarting preserves purchases and deliveries.", ++ }, ]; -@@ -220,6 +225,52 @@ export function createScenario(runtime) { - runtime.receiver.count(), + export async function requestOperation(baseUrl, name, input, role = "server") { +@@ -74,14 +81,26 @@ export function createScenario(runtime) { + }; + const start = checks.length; + if (stage === 0) { +- check("Fixture request matches the installed schema", validate(operation("verifyPurchase").input, evidence), true); ++ check( ++ "Fixture request matches the installed schema", ++ validate(operation("verifyPurchase").input, evidence), ++ true, ++ ); + check( + "Capabilities use the published response schema", + (await run("providerCapabilities", null, null)).httpStatus, + 200, + ); +- check("Purchase storage starts empty", runtime.provider.inspect().purchases, []); +- check("No profile conformance is claimed", (await run("providerCapabilities", null, null)).body.profiles, undefined); ++ check( ++ "Purchase storage starts empty", ++ runtime.provider.inspect().purchases, ++ [], ++ ); ++ check( ++ "No profile conformance is claimed", ++ (await run("providerCapabilities", null, null)).body.profiles, ++ undefined, ++ ); + } else if (stage === 1) { + check( + "Fixture evidence is accepted", +@@ -150,6 +169,11 @@ export function createScenario(runtime) { + (await run("entitlements", { userId: FIXTURE.userId })).body.productIds, + [FIXTURE.productId], + ); ++ check( ++ "First binding queues one grant; repeat and conflict queue none", ++ runtime.provider.inspect().deliveries.map((row) => row.eventType), ++ ["entitlement.granted"], ++ ); + } else if (stage === 3) { + runtime.time = FIXTURE.startsAt + 86_400_000; + runtime.provider.observe({ +@@ -168,7 +192,10 @@ export function createScenario(runtime) { + ); + check( + "Cancellation queues one event", +- runtime.provider.inspect().deliveries.length, ++ runtime.provider ++ .inspect() ++ .deliveries.filter((row) => row.eventType === "subscription.canceled") ++ .length, 1, ); + } else if (stage === 4) { +@@ -216,9 +243,55 @@ export function createScenario(runtime) { + ), + }); + check( +- "Redelivery has one durable inbox effect", ++ "Redelivery has one inbox row per event", + runtime.receiver.count(), +- 1, ++ 2, ++ ); + } else if (stage === 5) { + runtime.time = FIXTURE.expiresAt; + check( @@ -142,7 +1395,7 @@ index 366fb01..2d4e3b9 100644 + check( + "Receiver deduplication survives restart", + runtime.receiver.count(), -+ 1, ++ 2, + ); + responses.push({ + operation: "webhook: expiry + revocation", @@ -153,37 +1406,398 @@ index 366fb01..2d4e3b9 100644 + runtime.post, + ), + }); -+ check("All three events reach the receiver", runtime.receiver.count(), 3); ++ check("All four events reach the receiver", runtime.receiver.count(), 4); + check( + "The final status is inactive", + (await run("subscriptionStatus", { userId: FIXTURE.userId })).body + .active, + false, -+ ); + ); } const entry = { - step: stage + 1, +diff --git a/server.mjs b/server.mjs +index ad3e921..044c4c4 100644 +--- a/server.mjs ++++ b/server.mjs +@@ -7,7 +7,7 @@ import { createReceiver } from "./webhooks.mjs"; + import { createScenario, STAGES } from "./scenario.mjs"; + + export function startLab({ port = 0 } = {}) { +- const directory = mkdtempSync(join(tmpdir(), "commerce-lab-")); ++ const directory = mkdtempSync(join(tmpdir(), "commerce-example-")); + const runtime = { + time: FIXTURE.startsAt, + secret: randomBytes(32).toString("hex"), +@@ -85,7 +85,7 @@ export function startLab({ port = 0 } = {}) { + if (import.meta.main) { + const lab = startLab({ port: Number(process.env.COMMERCE_LAB_PORT ?? 5181) }); + console.log( +- `Commerce Lab: ${lab.runtime.baseUrl}\nDisposable SQLite files: ${lab.directory}\nFictional fixture store. No payments, credentials, or external services required.`, ++ `Commerce Protocol Example: ${lab.runtime.baseUrl}\nDisposable SQLite files: ${lab.directory}\nFictional fixture store. No payments, credentials, or external services required.`, + ); + for (const signal of ["SIGINT", "SIGTERM"]) + process.on(signal, async () => { +diff --git a/verify-erasure.mjs b/verify-erasure.mjs +deleted file mode 100644 +index d8ef01e..0000000 +--- a/verify-erasure.mjs ++++ /dev/null +@@ -1,153 +0,0 @@ +-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 "@hyodotdev/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 +deleted file mode 100644 +index 01bc808..0000000 +--- a/verify-stores.mjs ++++ /dev/null +@@ -1,173 +0,0 @@ +-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 117ecca..3884882 100644 +index 117ecca..ac3f22c 100644 --- a/verify.mjs +++ b/verify.mjs -@@ -1,9 +1,13 @@ +@@ -1,9 +1,15 @@ import assert from "node:assert/strict"; import { rmSync } from "node:fs"; -+import vectors from "openiap-commerce-protocol/vectors/signatures.json"; -+import { SUBSCRIPTION_STATES, WEBHOOK } from "openiap-commerce-protocol"; ++import vectors from "@hyodotdev/openiap-commerce-protocol/vectors/signatures.json"; ++import { SUBSCRIPTION_STATES, WEBHOOK } from "@hyodotdev/openiap-commerce-protocol"; +import { FIXTURE, isEntitled } from "./provider.mjs"; +import { requestOperation } from "./scenario.mjs"; ++import { runConsumerDemo } from "./consumer.mjs"; ++import { runBridgeDemo } from "./client-bridge.mjs"; import { startLab } from "./server.mjs"; -import { STAGES, requestOperation } from "./scenario.mjs"; -+import { authentic, deliver, sign } from "./webhooks.mjs"; ++import { authentic, createReceiver, deliver, sign } from "./webhooks.mjs"; -export async function verifyLab() { +export async function verifyLab({ compareSigner } = {}) { const lab = startLab(); const checks = []; const check = (label, actual, expected) => { -@@ -11,19 +15,194 @@ export async function verifyLab() { +@@ -11,19 +17,420 @@ export async function verifyLab() { checks.push(label); }; try { @@ -278,14 +1892,23 @@ index 117ecca..3884882 100644 + "UNSUPPORTED_PROFILE", + ); + check( -+ "Late cancellation cannot reopen expired access", ++ "Cancellation after expiry is ignored", + lab.runtime.provider.observe({ + id: "late-cancel", + kind: "cancel", -+ occurredAt: FIXTURE.startsAt + 1000, ++ occurredAt: FIXTURE.expiresAt, + }), + false, + ); ++ const expiredVerdict = await call("verifyPurchase", { ++ store: FIXTURE.store, ++ evidence: FIXTURE.evidence, ++ }); ++ check( ++ "Expired fixture evidence has an expired verdict", ++ [expiredVerdict.body.isValid, expiredVerdict.body.state], ++ [false, "EXPIRED"], ++ ); + const body = lab.runtime.provider.db + .query("SELECT body FROM outbox LIMIT 1") + .get().body; @@ -301,10 +1924,69 @@ index 117ecca..3884882 100644 + }), + ); + check("Tampered HTTP body has no inbox effect", invalid.status, 401); ++ const byteReceiver = createReceiver( ++ ":memory:", ++ lab.runtime.secret, ++ () => lab.runtime.time, ++ ); ++ try { ++ const event = { ...JSON.parse(body), userId: "demo_\uFFFD" }; ++ const unicode = Buffer.from(JSON.stringify(event)); ++ const offset = unicode.indexOf(Buffer.from("\uFFFD")); ++ const malformed = Buffer.concat([ ++ unicode.subarray(0, offset), ++ Buffer.from([0xff]), ++ unicode.subarray(offset + 3), ++ ]); ++ const withBom = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), unicode]); ++ const send = (bytes, signedBytes = unicode) => ++ byteReceiver.fetch( ++ new Request(`${lab.runtime.baseUrl}/demo/receiver`, { ++ method: "POST", ++ body: bytes, ++ headers: { ++ [WEBHOOK.timestampHeader]: timestamp, ++ [WEBHOOK.signatureHeader]: sign( ++ lab.runtime.secret, ++ timestamp, ++ signedBytes, ++ ), ++ [WEBHOOK.eventIdHeader]: event.eventId, ++ }, ++ }), ++ ); ++ for (const [label, bytes] of [ ++ ["Changed UTF-8 bytes", malformed], ++ ["Inserted UTF-8 BOM", withBom], ++ ]) { ++ check( ++ `${label} cannot reuse a signature`, ++ [(await send(bytes)).status, byteReceiver.count()], ++ [401, 0], ++ ); ++ } ++ check( ++ "Authenticated malformed UTF-8 is rejected before storage", ++ [(await send(malformed, malformed)).status, byteReceiver.count()], ++ [400, 0], ++ ); ++ check( ++ "Authentic Unicode bytes are accepted and stored", ++ [(await send(unicode)).status, byteReceiver.count()], ++ [200, 1], ++ ); ++ check( ++ "Authentic BOM bytes are verified before decoding", ++ [(await send(withBom, withBom)).status, byteReceiver.count()], ++ [200, 1], ++ ); ++ } finally { ++ byteReceiver.close(); ++ } + check( -+ "Receiver still has exactly three events", ++ "Receiver still has exactly four events", + lab.runtime.receiver.count(), -+ 3, ++ 4, + ); + check( + "Cross-origin demo mutations are refused", @@ -334,10 +2016,42 @@ index 117ecca..3884882 100644 + ), + ); + check( -+ "Concurrent ownership claims have one winner", ++ "Overlapping HTTP ownership claims have one winner", + bindings.filter((row) => row.body.bound).length, + 1, + ); ++ check( ++ "A cancellation older than the active row is ignored", ++ fresh.runtime.provider.observe({ ++ id: "old-active-cancel", ++ kind: "cancel", ++ occurredAt: FIXTURE.startsAt - 1, ++ }), ++ false, ++ ); ++ check( ++ "Ignoring an old cancellation preserves renewal", ++ fresh.runtime.provider.inspect().purchases[0].willRenew, ++ 1, ++ ); ++ assert.throws( ++ () => ++ fresh.runtime.provider.observe({ ++ id: "early-expiry", ++ kind: "expire", ++ occurredAt: FIXTURE.startsAt, ++ }), ++ /reconciliation/, ++ ); ++ check( ++ "Conflicting expiry is not consumed", ++ fresh.runtime.provider.db ++ .query( ++ "SELECT COUNT(*) AS count FROM observations WHERE id = 'early-expiry'", ++ ) ++ .get().count, ++ 0, ++ ); + fresh.runtime.time += 1000; + const observation = { + id: "atomic-cancel", @@ -377,6 +2091,132 @@ index 117ecca..3884882 100644 + await fresh.close(); + rmSync(fresh.directory, { recursive: true, force: true }); + } ++ const binding = startLab(); ++ try { ++ const call = (name, input) => ++ requestOperation(binding.runtime.baseUrl, name, input); ++ const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence }; ++ await call("verifyPurchase", evidence); ++ binding.runtime.provider.db.exec( ++ "CREATE TRIGGER fail_grant BEFORE INSERT ON outbox BEGIN SELECT RAISE(ABORT, 'injected disk failure'); END;", ++ ); ++ check( ++ "Grant failure rejects binding", ++ (await call("bindPurchase", { ...evidence, userId: FIXTURE.userId })) ++ .httpStatus, ++ 500, ++ ); ++ check( ++ "Grant failure rolls back ownership", ++ binding.runtime.provider.inspect().purchases[0].userId, ++ null, ++ ); ++ binding.runtime.provider.db.exec("DROP TRIGGER fail_grant"); ++ binding.runtime.time = FIXTURE.expiresAt; ++ check( ++ "An expired purchase can be bound", ++ (await call("bindPurchase", { ...evidence, userId: FIXTURE.userId })).body ++ .bound, ++ true, ++ ); ++ check( ++ "Binding expired evidence emits no grant", ++ binding.runtime.provider.inspect().deliveries, ++ [], ++ ); ++ } finally { ++ await binding.close(); ++ rmSync(binding.directory, { recursive: true, force: true }); ++ } ++ const delayed = startLab(); ++ try { ++ const call = (name, input) => ++ requestOperation(delayed.runtime.baseUrl, name, input); ++ const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence }; ++ await call("verifyPurchase", evidence); ++ delayed.runtime.time += 3_600_000; ++ await call("bindPurchase", { ...evidence, userId: FIXTURE.userId }); ++ const grant = JSON.parse( ++ delayed.runtime.provider.db.query("SELECT body FROM outbox LIMIT 1").get() ++ .body, ++ ); ++ check( ++ "Delayed binding retains the store occurrence", ++ grant.occurredAt, ++ FIXTURE.startsAt, ++ ); ++ check( ++ "Delayed binding records its actual processing time", ++ grant.processedAt, ++ delayed.runtime.time, ++ ); ++ delayed.runtime.restart(); ++ delayed.runtime.time = FIXTURE.expiresAt + 60_000; ++ delayed.runtime.provider.observe({ ++ id: "late-expiry", ++ kind: "expire", ++ occurredAt: delayed.runtime.time, ++ }); ++ check( ++ "Late expiry revokes a persisted grant exactly once", ++ delayed.runtime.provider.inspect().deliveries.map((row) => row.eventType), ++ ["entitlement.granted", "subscription.expired", "entitlement.revoked"], ++ ); ++ delayed.runtime.provider.observe({ ++ id: "another-expiry", ++ kind: "expire", ++ occurredAt: delayed.runtime.time, ++ }); ++ check( ++ "Repeated expiry emits no second revocation", ++ delayed.runtime.provider.inspect().deliveries.length, ++ 3, ++ ); ++ } finally { ++ await delayed.close(); ++ rmSync(delayed.directory, { recursive: true, force: true }); ++ } ++ const equalTime = startLab(); ++ try { ++ const call = (name, input) => ++ requestOperation(equalTime.runtime.baseUrl, name, input); ++ const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence }; ++ await call("verifyPurchase", evidence); ++ await call("bindPurchase", { ...evidence, userId: FIXTURE.userId }); ++ equalTime.runtime.time = FIXTURE.expiresAt; ++ equalTime.runtime.provider.observe({ ++ id: "cancel-at-deadline", ++ kind: "cancel", ++ occurredAt: FIXTURE.expiresAt, ++ }); ++ equalTime.runtime.provider.observe({ ++ id: "expire-at-deadline", ++ kind: "expire", ++ occurredAt: FIXTURE.expiresAt, ++ }); ++ check( ++ "Equal-time expiry retains its lifecycle transition", ++ equalTime.runtime.provider.inspect().purchases[0].state, ++ "Expired", ++ ); ++ check( ++ "Equal-time observations revoke once and preserve both transitions", ++ equalTime.runtime.provider ++ .inspect() ++ .deliveries.map((row) => row.eventType), ++ [ ++ "entitlement.granted", ++ "subscription.canceled", ++ "entitlement.revoked", ++ "subscription.expired", ++ ], ++ ); ++ } finally { ++ await equalTime.close(); ++ rmSync(equalTime.directory, { recursive: true, force: true }); ++ } ++ checks.push(...(await runConsumerDemo()).checks); ++ checks.push(...runBridgeDemo()); + return checks; } + @@ -384,6 +2224,47 @@ index 117ecca..3884882 100644 const checks = await verifyLab(); - console.log(JSON.stringify({passed: checks.length, checks}, null, 2)); + console.log( -+ `Commerce Lab: ${checks.length} checks passed. No store or production service contacted.`, ++ `Commerce Protocol Example: ${checks.length} checks passed. No store or production service contacted.`, + ); } +diff --git a/webhooks.mjs b/webhooks.mjs +index f3f38d3..50c9027 100644 +--- a/webhooks.mjs ++++ b/webhooks.mjs +@@ -6,7 +6,10 @@ import { validate } from "./contract.mjs"; + export function sign(secret, timestamp, body) { + return ( + WEBHOOK.signaturePrefix + +- createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex") ++ createHmac("sha256", secret) ++ .update(`${timestamp}.`) ++ .update(body) ++ .digest("hex") + ); + } + +@@ -36,20 +39,21 @@ export function createReceiver(path, secret, now) { + ); + + async function fetch(request) { +- const body = await request.text(); ++ const bytes = new Uint8Array(await request.arrayBuffer()); + if ( + !authentic( + [secret], + request.headers.get(WEBHOOK.timestampHeader), +- body, ++ bytes, + request.headers.get(WEBHOOK.signatureHeader), + Math.floor(now() / 1000), + ) + ) { + return new Response("Invalid signature", { status: 401 }); + } +- let event; ++ let body, event; + try { ++ body = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + event = JSON.parse(body); + } catch { + return new Response("Invalid JSON", { status: 400 }); diff --git a/docs/build/06-recover/mobile.png b/docs/build/06-recover/mobile.png index 6798cbd..7e835b9 100644 Binary files a/docs/build/06-recover/mobile.png and b/docs/build/06-recover/mobile.png differ diff --git a/docs/build/06-recover/run.json b/docs/build/06-recover/run.json index f1a75a6..0c36eaf 100644 --- a/docs/build/06-recover/run.json +++ b/docs/build/06-recover/run.json @@ -2,29 +2,38 @@ "step": 6, "id": "06-recover", "title": "Expire access and restart", - "built": "Expiry-aware reads + recovery from SQLite", + "built": "Exact-byte webhook authentication with Unicode regression checks", "result": "Access closes at the deadline. Restarting preserves purchases and deliveries.", - "startedAt": "2026-09-07T12:09:47.057Z", - "recordedAt": "2026-09-07T12:09:48.778Z", - "packageVersion": "0.1.0", - "task": "# Expire access and restart\n\nAdd expiry observations and recovery checks. Recheck access at the exact deadline before a notification arrives. Prove duplicate and late observations cannot reopen access. Review the full implementation and repeat failing checks after every correction.\n\nUse the existing reviewed prototype where it satisfies the installed contract.\nRun the backend, inspect the result, correct problems, and rerun the affected check.\nKeep all work uncommitted.\n", + "previous": "05-deliver", + "startedAt": "2026-09-16T00:54:02.471Z", + "recordedAt": "2026-09-16T00:54:04.395Z", + "packageVersion": "0.3.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": { - "checkpoint.json": "0c0a890da5b63c79c9ee3659c7958ca07c03a3f3f9b4e49224d7fdc966a21bba", + ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", + ".yarnrc.yml": "473e6def86fc03638120e0c01d0c8bbab095677256460fa4ea763e5d4697f270", + "BUILD.md": "e26c2e60273fc9fed20b1a35dee1b9c309ee29f82c1b9bc902aca8f4ad2951bf", + "INTEGRATE.md": "8d08b34132af251f372d342b51d8719f5c37a282b5fea7b5c1865654e66fd8e1", "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", - "provider.mjs": "a8db8f523057012ce468bc24d997973910d9feeb567833cfd44222741e3fc484", - "capture.mjs": "43309016734e2505ea83948656d4d36ac3f02418ad8e4ea4e7b8fd1d4fc344e4", - "ai-task.md": "961e0f4858a557f891fcca19afaba9fe0ceeafbbf3795e7a108f09594687c231", - "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", + "README.md": "0828c32a4a5611599e6cc8441f824f299b4c59e4e8a6d9db89fcc6a999dae0fa", + "ai-task.md": "d000a11054750accc6bab8ddbf6d2fc6b674b12c3821ccbfd45729469923b8fe", + "capture.mjs": "076472781d7f90522282fbdce48b32a00499e9eed04916e8894d5e392d936292", + "checkpoint-tools.mjs": "09da9b218f7365e1e7995c5a49c6247faf7fac24463843c44d62e097d167bed0", + "checkpoint-tools.test.mjs": "9c71b5f95f33fe3352f20836a57d063ed5c680fa0837ae4de399bc820b5d3896", + "checkpoint.json": "bc8538d7b648b8e91b4070af7e1f5cf4968df2a0277a73987ebdde96aa40a04c", + "client-bridge.mjs": "a0058579b02c2f3a770c7ff7b59dfe41ce50761361d9a0c7a2df96ab1b6b4acf", + "consumer.mjs": "2eb026f3e2d7446c0538c0e9c02bb041a9d44b586b5840d80ddc80fdfe3f795d", + "contract.mjs": "474dfee661cf00617872f6f50c3f4f5e88706e80d2fd6fcdda9304f489268c73", "dashboard.html": "7af8dacc8023922eb7705d8160c583b385eb4c293fa1b0b38327e92aa816cf9c", - "verify.mjs": "3c02ecaa00e93d0af107c7dc61a26460c59cb72402d5906878ec681c13cec70e", - "README.md": "ea83c0f24a18005c4e86aa3a951f4a7ac0f605537a7aa0e8fe2a179564a25771", - ".gitignore": "50b276cb902abd8b3018e368cd251db0a5c36b289b00a7fffd18e0e399c7f637", - "package-lock.json": "7b7e6d588b495fd74020864325d8004ad797fb6b7fc563fc373f07c5c2501479", - "package.json": "191b82f635869339c5ae0ec9071115deba3813a73cc6060d99899b9290e38731", - "BUILD.md": "a21280d90624e644da240d309b726bc8c7d07c1eddcafe7e3c2333d8451e6643", - "webhooks.mjs": "6124cf3bdb4404c2f5a712ae8e9fc8e3382a169f8aa79d0e5476a7d5773b9800", - "server.mjs": "44fe1930e987c0aca101d0360cd3a6cb552d344c443ea62aead9cd0e607ea143", - "scenario.mjs": "4e5da4cc6e0687c8a2e45312ec9ee03bcccb65f21d320a1b8c55f749a18d24b7" + "export-docs.mjs": "4a6527b1b8a533283ae7d0597a5ef38b9eaf97e94bfc841c6e958170dc611785", + "package-lock.json": "c6df602e11ad7abfb25bf5374b2c76df6b7a82945d27a8d37e760f6d675792f4", + "package.json": "e00afc74197ede5c0fff903a7653f1a8f1f5dc8850a9017ddf74f1949d522c46", + "provider.mjs": "6dcaeca65460c23ba2ef09c464ed2388e01deb7dcb9fa6a54af83575d8f4ebd5", + "scenario.mjs": "d8502ba81eb09fe42d574a38cd893c3acf6cf9ad8f43f91044e967a282920860", + "server.mjs": "b0f185a3954fbe1d7a4dd15f0dc8638afcf04e386ec730ae17a15860784d9e36", + "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", + "verify.mjs": "b58acae2edd46f2e8f2ee2aee8fa1246d2bab619fcafd7a2972089c3b8378e71", + "webhooks.mjs": "db3c142453e2872ec32ba7c577a1a92253128e142c4f80b01838fed8e1a4add8" }, "checks": [ "Fixture request matches the installed schema", @@ -40,18 +49,19 @@ "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 durable inbox effect", + "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 three events reach the receiver", + "All four events reach the receiver", "The final status is inactive", "Signature vector: single-key", "Receiver accepts vector: single-key", @@ -101,14 +111,69 @@ "Malformed input is refused", "A real store is not falsely accepted", "Erasure is explicitly unimplemented", - "Late cancellation cannot reopen expired access", + "Cancellation after expiry is ignored", + "Expired fixture evidence has an expired verdict", "Tampered HTTP body has no inbox effect", - "Receiver still has exactly three events", + "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", - "Concurrent ownership claims have one winner", + "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" + "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" ], "history": [ { @@ -130,11 +195,12 @@ "operation": "providerCapabilities", "httpStatus": 200, "body": { - "specVersion": "1.0", + "commerceProtocolVersion": "1.0", "implementation": { "name": "Commerce Protocol Example — fictional fixture store" }, "eventTypes": [ + "entitlement.granted", "subscription.canceled", "subscription.expired", "entitlement.revoked" @@ -194,11 +260,12 @@ "operation": "providerCapabilities", "httpStatus": 200, "body": { - "specVersion": "1.0", + "commerceProtocolVersion": "1.0", "implementation": { "name": "Commerce Protocol Example — fictional fixture store" }, "eventTypes": [ + "entitlement.granted", "subscription.canceled", "subscription.expired", "entitlement.revoked" @@ -364,7 +431,15 @@ } ] }, - "deliveries": [], + "deliveries": [ + { + "eventId": "bdd2f9b3-c251-4f5c-a541-af54930d123a", + "deliveryId": "44b4c906-ea79-433d-ba0a-e5370139cf17", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + } + ], "inboxCount": 0, "responses": [ { @@ -424,9 +499,10 @@ "The server binds Alice", "Repeating the same binding succeeds", "Bob cannot take Alice's purchase", - "Alice can access Premium" + "Alice can access Premium", + "First binding queues one grant; repeat and conflict queue none" ], - "totalChecks": 13 + "totalChecks": 14 }, { "step": 4, @@ -460,8 +536,15 @@ }, "deliveries": [ { - "eventId": "82a8dd50-1a61-4c08-b848-55413b1af3c4", - "deliveryId": "eeeea9b4-84de-41cf-bba4-4f5813f2d3c9", + "eventId": "bdd2f9b3-c251-4f5c-a541-af54930d123a", + "deliveryId": "44b4c906-ea79-433d-ba0a-e5370139cf17", + "attempts": 0, + "status": "pending", + "eventType": "entitlement.granted" + }, + { + "eventId": "9179a445-b6d0-4cb3-898d-0919da9b19c1", + "deliveryId": "0e60d0e5-0aae-4482-85e8-e56bf3b2c900", "attempts": 0, "status": "pending", "eventType": "subscription.canceled" @@ -490,7 +573,7 @@ "Cancellation stops renewal", "Cancellation queues one event" ], - "totalChecks": 16 + "totalChecks": 17 }, { "step": 5, @@ -524,21 +607,35 @@ }, "deliveries": [ { - "eventId": "82a8dd50-1a61-4c08-b848-55413b1af3c4", - "deliveryId": "eeeea9b4-84de-41cf-bba4-4f5813f2d3c9", + "eventId": "bdd2f9b3-c251-4f5c-a541-af54930d123a", + "deliveryId": "44b4c906-ea79-433d-ba0a-e5370139cf17", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "9179a445-b6d0-4cb3-898d-0919da9b19c1", + "deliveryId": "0e60d0e5-0aae-4482-85e8-e56bf3b2c900", "attempts": 3, "status": "delivered", "eventType": "subscription.canceled" } ], - "inboxCount": 1, + "inboxCount": 2, "responses": [ { "operation": "webhook: receiver unavailable", "body": [ { - "eventId": "82a8dd50-1a61-4c08-b848-55413b1af3c4", - "deliveryId": "eeeea9b4-84de-41cf-bba4-4f5813f2d3c9", + "eventId": "bdd2f9b3-c251-4f5c-a541-af54930d123a", + "deliveryId": "44b4c906-ea79-433d-ba0a-e5370139cf17", + "httpStatus": 503, + "attempt": 1, + "status": "pending" + }, + { + "eventId": "9179a445-b6d0-4cb3-898d-0919da9b19c1", + "deliveryId": "0e60d0e5-0aae-4482-85e8-e56bf3b2c900", "httpStatus": 503, "attempt": 1, "status": "pending" @@ -549,8 +646,15 @@ "operation": "webhook: retry after restart", "body": [ { - "eventId": "82a8dd50-1a61-4c08-b848-55413b1af3c4", - "deliveryId": "eeeea9b4-84de-41cf-bba4-4f5813f2d3c9", + "eventId": "bdd2f9b3-c251-4f5c-a541-af54930d123a", + "deliveryId": "44b4c906-ea79-433d-ba0a-e5370139cf17", + "httpStatus": 200, + "attempt": 2, + "status": "delivered" + }, + { + "eventId": "9179a445-b6d0-4cb3-898d-0919da9b19c1", + "deliveryId": "0e60d0e5-0aae-4482-85e8-e56bf3b2c900", "httpStatus": 200, "attempt": 2, "status": "delivered" @@ -561,8 +665,15 @@ "operation": "webhook: lost-ack redelivery", "body": [ { - "eventId": "82a8dd50-1a61-4c08-b848-55413b1af3c4", - "deliveryId": "eeeea9b4-84de-41cf-bba4-4f5813f2d3c9", + "eventId": "bdd2f9b3-c251-4f5c-a541-af54930d123a", + "deliveryId": "44b4c906-ea79-433d-ba0a-e5370139cf17", + "httpStatus": 200, + "attempt": 3, + "status": "delivered" + }, + { + "eventId": "9179a445-b6d0-4cb3-898d-0919da9b19c1", + "deliveryId": "0e60d0e5-0aae-4482-85e8-e56bf3b2c900", "httpStatus": 200, "attempt": 3, "status": "delivered" @@ -574,9 +685,9 @@ "A 503 leaves a durable retry", "Retry survives provider restart", "Retry keeps the delivery identity", - "Redelivery has one durable inbox effect" + "Redelivery has one inbox row per event" ], - "totalChecks": 20 + "totalChecks": 21 }, { "step": 6, @@ -599,28 +710,35 @@ }, "deliveries": [ { - "eventId": "82a8dd50-1a61-4c08-b848-55413b1af3c4", - "deliveryId": "eeeea9b4-84de-41cf-bba4-4f5813f2d3c9", + "eventId": "bdd2f9b3-c251-4f5c-a541-af54930d123a", + "deliveryId": "44b4c906-ea79-433d-ba0a-e5370139cf17", + "attempts": 3, + "status": "delivered", + "eventType": "entitlement.granted" + }, + { + "eventId": "9179a445-b6d0-4cb3-898d-0919da9b19c1", + "deliveryId": "0e60d0e5-0aae-4482-85e8-e56bf3b2c900", "attempts": 3, "status": "delivered", "eventType": "subscription.canceled" }, { - "eventId": "6ee530ab-91d9-465c-8c2a-e8b75605a0f3", - "deliveryId": "4c9dcccb-198a-4ce5-a8e1-b7aef32ec4c7", + "eventId": "51b13f70-6288-4a41-8375-7f79ba6eb0c6", + "deliveryId": "b40ccd25-d42b-400b-ab9f-71769a2854c4", "attempts": 1, "status": "delivered", "eventType": "subscription.expired" }, { - "eventId": "7b69e75a-34cb-4404-8098-27595431ac5f", - "deliveryId": "8b30511e-0a93-4b1f-be5b-52c86b73ab7c", + "eventId": "c8343754-6fd0-4138-a399-99503707d888", + "deliveryId": "73cda4d4-122f-42a8-9a8f-7c103f76c388", "attempts": 1, "status": "delivered", "eventType": "entitlement.revoked" } ], - "inboxCount": 3, + "inboxCount": 4, "responses": [ { "operation": "entitlements", @@ -635,15 +753,15 @@ "operation": "webhook: expiry + revocation", "body": [ { - "eventId": "6ee530ab-91d9-465c-8c2a-e8b75605a0f3", - "deliveryId": "4c9dcccb-198a-4ce5-a8e1-b7aef32ec4c7", + "eventId": "51b13f70-6288-4a41-8375-7f79ba6eb0c6", + "deliveryId": "b40ccd25-d42b-400b-ab9f-71769a2854c4", "httpStatus": 200, "attempt": 1, "status": "delivered" }, { - "eventId": "7b69e75a-34cb-4404-8098-27595431ac5f", - "deliveryId": "8b30511e-0a93-4b1f-be5b-52c86b73ab7c", + "eventId": "c8343754-6fd0-4138-a399-99503707d888", + "deliveryId": "73cda4d4-122f-42a8-9a8f-7c103f76c388", "httpStatus": 200, "attempt": 1, "status": "delivered" @@ -671,12 +789,12 @@ "Duplicate store notification emits no extra event", "Reopening both databases preserves state", "Receiver deduplication survives restart", - "All three events reach the receiver", + "All four events reach the receiver", "The final status is inactive" ], - "totalChecks": 26 + "totalChecks": 27 } ], "screenshot": "screen.png", - "scope": "Incremental source checkpoint adapted from the reviewed Commerce Lab prototype. HTTP and SQLite execute locally; store and clock are fixtures. No full profile claim." + "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/06-recover/screen.png b/docs/build/06-recover/screen.png index 458209d..de5be8a 100644 Binary files a/docs/build/06-recover/screen.png and b/docs/build/06-recover/screen.png differ diff --git a/docs/build/06-recover/source.tar.gz b/docs/build/06-recover/source.tar.gz index 98eba4f..d704fe1 100644 Binary files a/docs/build/06-recover/source.tar.gz and b/docs/build/06-recover/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..f66ce2c --- /dev/null +++ b/docs/build/07-account-erasure/attempt-1.txt @@ -0,0 +1,20 @@ +{ + "startedAt": "2026-09-16T00:54:10.440Z", + "commands": [ + { + "command": "npm ci --ignore-scripts --no-audit --no-fund", + "exitCode": 0, + "output": "\nadded 10 packages in 211ms\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\n 3 pass\n 0 fail\n 20 expect() calls\nRan 3 tests across 2 files. [137.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\n 5 pass\n 0 fail\nRan 5 tests across 1 file. [65.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..5cdb0b1 --- /dev/null +++ b/docs/build/07-account-erasure/changes.patch @@ -0,0 +1,3137 @@ +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 d8f85e1..9b5ed75 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 9697fda..e0574ba 100644 +--- a/checkpoint.json ++++ b/checkpoint.json +@@ -1,8 +1,8 @@ + { +- "step": 6, +- "id": "06-recover", +- "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": "05-deliver" ++ "step": 7, ++ "id": "07-account-erasure", ++ "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": "06-recover" + } +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..b548ab2 +--- /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 "@hyodotdev/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 = { ++ commerceProtocolVersion: 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..2e2ee9d +--- /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 "@hyodotdev/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..cbb160f +--- /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 "@hyodotdev/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.commerceProtocolVersion.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 37a5bec..72eb7cb 100644 +--- a/consumer.mjs ++++ b/consumer.mjs +@@ -7,7 +7,7 @@ import { WEBHOOK, COMMERCE_EVENT_VERSION } from "@hyodotdev/openiap-commerce-pro + 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 @@ + 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/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-lock.json b/package-lock.json +index 78fad4d..6ac385f 100644 +--- a/package-lock.json ++++ b/package-lock.json +@@ -61,9 +61,9 @@ + "license": "MIT" + }, + "node_modules/fast-uri": { +- "version": "3.1.8", +- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", +- "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", ++ "version": "3.1.7", ++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", ++ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", +diff --git a/package.json b/package.json +index e52dc85..268ccac 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 cf3c935..4b0c2e5 100644 +--- a/provider.mjs ++++ b/provider.mjs +@@ -6,6 +6,7 @@ import { + HTTP_BINDING, + } from "@hyodotdev/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..d8ef01e +--- /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 "@hyodotdev/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 ac3f22c..92fc76b 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 50c9027..df8523d 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 "@hyodotdev/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, diff --git a/docs/build/07-account-erasure/mobile.png b/docs/build/07-account-erasure/mobile.png new file mode 100644 index 0000000..c5c65fa Binary files /dev/null and b/docs/build/07-account-erasure/mobile.png differ diff --git a/docs/build/06-recover-reviewed-8/run.json b/docs/build/07-account-erasure/run.json similarity index 63% rename from docs/build/06-recover-reviewed-8/run.json rename to docs/build/07-account-erasure/run.json index 276d142..03ebb1d 100644 --- a/docs/build/06-recover-reviewed-8/run.json +++ b/docs/build/07-account-erasure/run.json @@ -1,39 +1,52 @@ { - "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", - "startedAt": "2026-09-07T18:19:57.597Z", - "recordedAt": "2026-09-07T18:20:02.056Z", - "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", + "step": 7, + "id": "07-account-erasure", + "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": "06-recover", + "startedAt": "2026-09-16T00:54:10.440Z", + "recordedAt": "2026-09-16T00:54:12.662Z", + "packageVersion": "0.3.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", - "BUILD.md": "e26c2e60273fc9fed20b1a35dee1b9c309ee29f82c1b9bc902aca8f4ad2951bf", - "INTEGRATE.md": "8d08b34132af251f372d342b51d8719f5c37a282b5fea7b5c1865654e66fd8e1", + "AGENTS.md": "38764ea47552533ab5db55f7ade8c0025e2d7af9163715ce89f2d719f5475712", + "BUILD.md": "6cbf93448cf2d1925d24517a4dcac72f92f8b04538b5810cb95d0db005f7130b", + "INTEGRATE.md": "ed3851d6432deedb85d2d0cc3edcca8fe52f58c79c99b68a9aa54616b010949b", "LICENSE": "243adbe048bbec60be2faf9ae1e62c5221327d20ffbc13b1c80e82fa98127af1", - "README.md": "0828c32a4a5611599e6cc8441f824f299b4c59e4e8a6d9db89fcc6a999dae0fa", - "ai-task.md": "d000a11054750accc6bab8ddbf6d2fc6b674b12c3821ccbfd45729469923b8fe", - "capture.mjs": "1c3e785d5282359c346240c66eeaed70404758924d61fdd6f798f367988cd96c", - "checkpoint-tools.mjs": "09da9b218f7365e1e7995c5a49c6247faf7fac24463843c44d62e097d167bed0", - "checkpoint-tools.test.mjs": "9c71b5f95f33fe3352f20836a57d063ed5c680fa0837ae4de399bc820b5d3896", - "checkpoint.json": "d063bccc3c6ed0a075810364c3a1737156883ccb09afefb92129550d329fa14f", - "client-bridge.mjs": "a0058579b02c2f3a770c7ff7b59dfe41ce50761361d9a0c7a2df96ab1b6b4acf", - "consumer.mjs": "3bd1a07e87e5e367775a3deadc71cd205dfc8e9757e632f2406791ae5db7ad73", - "contract.mjs": "c97b51ed48875303c382059af5dc321005d677ab2ea834a6e00c060308bc2ab2", - "dashboard.html": "7af8dacc8023922eb7705d8160c583b385eb4c293fa1b0b38327e92aa816cf9c", - "export-docs.mjs": "4a6527b1b8a533283ae7d0597a5ef38b9eaf97e94bfc841c6e958170dc611785", - "package-lock.json": "6f5333cc45203d6a27fec82c9370a8c25fef7faab591467dd2ff3c79acd6ae1c", - "package.json": "6ff038e4eb85ed5e17e5e7b91149232a5c62e589e7a4083f152aa18dde944c92", - "provider.mjs": "67e0e5b2699c84444677f3f30c1c2a020d82976bfb203e508837a51e4d9920ae", - "scenario.mjs": "d8502ba81eb09fe42d574a38cd893c3acf6cf9ad8f43f91044e967a282920860", + "README.md": "31b768b8b9d031a7f95f912919ac3ea61e2e394a41d9762af00f0e6276e6ff03", + "ai-task.md": "2577865cb3e1a38dca6159d283f7c2140af15e89a5249029cd24a99153bd3e6b", + "capture.mjs": "c77046326f3a5a7c112e0d30e0fe23817b58104f8b2807a73ee380531ffbc2ed", + "checkpoint-tools.mjs": "137fb2798a7e73e2db9a716d8cd3ea78fe3155ab0651ecae39f1e629634ec19c", + "checkpoint-tools.test.mjs": "1ea348330db8dab85694ac79cfec76e9d3af1729263a6a05cae61e10e41b2175", + "checkpoint.json": "92fc8372f5603c2fbdcf0c6b54dd01309dc6e32f98cb3a0c94f679f7982368a3", + "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": "e07417405374d7caad01de5c527445176097385750b3f7b9b74bbc49a0e4147f", + "composition/purchase-flow.mjs": "ce6f98426c36eab346e900ac06ed31f2d387e542c518a48c92a67f7bae147ec8", + "composition/receiver.test.mjs": "f26fd065ac77d7121d9abd1e418fd0ba622762b8759f5949b2e2fc540576910a", + "composition/run.mjs": "6974e2ae16bd84fbb1bbb583beef7e2e0774b6b41322ebe72b8ae059445a546e", + "consumer.mjs": "f9cdc5eff17f322dd2ae146546cfc5ed9b5f1a02495294b83bc17ec227aff18f", + "contract.mjs": "474dfee661cf00617872f6f50c3f4f5e88706e80d2fd6fcdda9304f489268c73", + "dashboard.html": "50a19acc0810453fa2ef55f4e5e958b4ba428764fc5a5edc10e262f9c990e532", + "erasure.mjs": "2dddb3ffd929c9d05333e88193f7cb3e5874ef5b29e68c68a1b25997d09788ea", + "export-docs.mjs": "47cf25c7ba1942fb833ac6b496c1e06b4ae06a61f6328330c6a6a4337c164f84", + "package-lock.json": "fbcf20a1a02f208ccc5f5648207b42cf87c5550fc0df62a8870dedfeb5e0d23e", + "package.json": "9be61e89a0de195d9fc3ccc7199c073bee0610c475c0cf3ee71387bbd1df6b24", + "provider.mjs": "c130e486f7b52c28caab70ce469b50e5169e6bb673217cd5af0de5ce16b67ef8", + "scenario.mjs": "b8ff9024afd67701b5f70916a61fa7d538eaec281aa4b5cc660e1c205932a32d", "server.mjs": "b0f185a3954fbe1d7a4dd15f0dc8638afcf04e386ec730ae17a15860784d9e36", "verify-checkpoints.mjs": "ece22b0480bafa41ad8c65e37a5705eaf67c3f8b47c62339f9b1b33a90edd6d2", - "verify.mjs": "61a2dc1e2f4042ff5530badd3caaa6e43c93df8c03996aa9e6dc3b544807e4d3", - "webhooks.mjs": "60ee5a726ecd664e0e13b7eb3f44199abb5d600c696ccfca816e7d070104d2a0" + "verify-erasure.mjs": "5d6864387941a103d1f92b441eb34c96a50026068ead13c8b669ba06f7b7be4f", + "verify-stores.mjs": "6a9ea6071aebddac158435b8577f4b31914d902191f6cc1476683ea6d9bac3dd", + "verify.mjs": "459d5be0394e0fe6a23e7476f80d3ca2fdb214dde7f21de1ad71eee4afd98267", + "webhooks.mjs": "e6da4eb939244f1b05ad61307f717a2214bf830da9d27993beb969676cc936a6" }, "checks": [ "Fixture request matches the installed schema", @@ -110,7 +123,7 @@ "Verification role cannot enumerate users", "Malformed input is refused", "A real store is not falsely accepted", - "Erasure is explicitly unimplemented", + "Erasure rejects verification credentials", "Cancellation after expiry is ignored", "Expired fixture evidence has an expired verdict", "Tampered HTTP body has no inbox effect", @@ -166,14 +179,105 @@ "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 needs its own adapter", - "Rejects Horizon needs its own adapter", + "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" + "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": [ { @@ -195,7 +299,7 @@ "operation": "providerCapabilities", "httpStatus": 200, "body": { - "specVersion": "1.0", + "commerceProtocolVersion": "1.0", "implementation": { "name": "Commerce Protocol Example — fictional fixture store" }, @@ -260,7 +364,7 @@ "operation": "providerCapabilities", "httpStatus": 200, "body": { - "specVersion": "1.0", + "commerceProtocolVersion": "1.0", "implementation": { "name": "Commerce Protocol Example — fictional fixture store" }, @@ -433,8 +537,8 @@ }, "deliveries": [ { - "eventId": "316f329e-6fd0-469d-886b-7c7ed89ae402", - "deliveryId": "35fb2452-d9c2-407b-baa0-729eab3d89be", + "eventId": "1de246c2-ad34-4ffb-aebb-491ac12e2d7f", + "deliveryId": "828acb78-f887-4ae0-8841-1dc87eaa93ca", "attempts": 0, "status": "pending", "eventType": "entitlement.granted" @@ -536,15 +640,15 @@ }, "deliveries": [ { - "eventId": "316f329e-6fd0-469d-886b-7c7ed89ae402", - "deliveryId": "35fb2452-d9c2-407b-baa0-729eab3d89be", + "eventId": "1de246c2-ad34-4ffb-aebb-491ac12e2d7f", + "deliveryId": "828acb78-f887-4ae0-8841-1dc87eaa93ca", "attempts": 0, "status": "pending", "eventType": "entitlement.granted" }, { - "eventId": "79aaa97c-c341-465d-98ae-a05a15a1ac7d", - "deliveryId": "41cb1bd1-a65b-493a-a609-5dc639643143", + "eventId": "a32f64e6-7a6a-457a-9a97-658a566d4212", + "deliveryId": "bb2cc849-3a88-4c03-8fc1-50816f808e78", "attempts": 0, "status": "pending", "eventType": "subscription.canceled" @@ -607,15 +711,15 @@ }, "deliveries": [ { - "eventId": "316f329e-6fd0-469d-886b-7c7ed89ae402", - "deliveryId": "35fb2452-d9c2-407b-baa0-729eab3d89be", + "eventId": "1de246c2-ad34-4ffb-aebb-491ac12e2d7f", + "deliveryId": "828acb78-f887-4ae0-8841-1dc87eaa93ca", "attempts": 3, "status": "delivered", "eventType": "entitlement.granted" }, { - "eventId": "79aaa97c-c341-465d-98ae-a05a15a1ac7d", - "deliveryId": "41cb1bd1-a65b-493a-a609-5dc639643143", + "eventId": "a32f64e6-7a6a-457a-9a97-658a566d4212", + "deliveryId": "bb2cc849-3a88-4c03-8fc1-50816f808e78", "attempts": 3, "status": "delivered", "eventType": "subscription.canceled" @@ -627,15 +731,15 @@ "operation": "webhook: receiver unavailable", "body": [ { - "eventId": "316f329e-6fd0-469d-886b-7c7ed89ae402", - "deliveryId": "35fb2452-d9c2-407b-baa0-729eab3d89be", + "eventId": "1de246c2-ad34-4ffb-aebb-491ac12e2d7f", + "deliveryId": "828acb78-f887-4ae0-8841-1dc87eaa93ca", "httpStatus": 503, "attempt": 1, "status": "pending" }, { - "eventId": "79aaa97c-c341-465d-98ae-a05a15a1ac7d", - "deliveryId": "41cb1bd1-a65b-493a-a609-5dc639643143", + "eventId": "a32f64e6-7a6a-457a-9a97-658a566d4212", + "deliveryId": "bb2cc849-3a88-4c03-8fc1-50816f808e78", "httpStatus": 503, "attempt": 1, "status": "pending" @@ -646,15 +750,15 @@ "operation": "webhook: retry after restart", "body": [ { - "eventId": "316f329e-6fd0-469d-886b-7c7ed89ae402", - "deliveryId": "35fb2452-d9c2-407b-baa0-729eab3d89be", + "eventId": "1de246c2-ad34-4ffb-aebb-491ac12e2d7f", + "deliveryId": "828acb78-f887-4ae0-8841-1dc87eaa93ca", "httpStatus": 200, "attempt": 2, "status": "delivered" }, { - "eventId": "79aaa97c-c341-465d-98ae-a05a15a1ac7d", - "deliveryId": "41cb1bd1-a65b-493a-a609-5dc639643143", + "eventId": "a32f64e6-7a6a-457a-9a97-658a566d4212", + "deliveryId": "bb2cc849-3a88-4c03-8fc1-50816f808e78", "httpStatus": 200, "attempt": 2, "status": "delivered" @@ -665,15 +769,15 @@ "operation": "webhook: lost-ack redelivery", "body": [ { - "eventId": "316f329e-6fd0-469d-886b-7c7ed89ae402", - "deliveryId": "35fb2452-d9c2-407b-baa0-729eab3d89be", + "eventId": "1de246c2-ad34-4ffb-aebb-491ac12e2d7f", + "deliveryId": "828acb78-f887-4ae0-8841-1dc87eaa93ca", "httpStatus": 200, "attempt": 3, "status": "delivered" }, { - "eventId": "79aaa97c-c341-465d-98ae-a05a15a1ac7d", - "deliveryId": "41cb1bd1-a65b-493a-a609-5dc639643143", + "eventId": "a32f64e6-7a6a-457a-9a97-658a566d4212", + "deliveryId": "bb2cc849-3a88-4c03-8fc1-50816f808e78", "httpStatus": 200, "attempt": 3, "status": "delivered" @@ -710,29 +814,29 @@ }, "deliveries": [ { - "eventId": "316f329e-6fd0-469d-886b-7c7ed89ae402", - "deliveryId": "35fb2452-d9c2-407b-baa0-729eab3d89be", + "eventId": "1de246c2-ad34-4ffb-aebb-491ac12e2d7f", + "deliveryId": "828acb78-f887-4ae0-8841-1dc87eaa93ca", "attempts": 3, "status": "delivered", "eventType": "entitlement.granted" }, { - "eventId": "79aaa97c-c341-465d-98ae-a05a15a1ac7d", - "deliveryId": "41cb1bd1-a65b-493a-a609-5dc639643143", + "eventId": "a32f64e6-7a6a-457a-9a97-658a566d4212", + "deliveryId": "bb2cc849-3a88-4c03-8fc1-50816f808e78", "attempts": 3, "status": "delivered", "eventType": "subscription.canceled" }, { - "eventId": "59fb04a6-cc0c-483a-b8ce-6b19e1730473", - "deliveryId": "e19fbfe7-93a1-4705-9ff5-fe325d113674", + "eventId": "69e93b80-aa53-4223-b50a-dff178f13d35", + "deliveryId": "1a5ecc7b-b7ce-44f6-bb0f-6c68cea5b9c1", "attempts": 1, "status": "delivered", "eventType": "subscription.expired" }, { - "eventId": "f51af7b5-ee42-4c47-a91b-80c6bd94a39d", - "deliveryId": "d2304ba1-d8bf-4bbb-bfa8-f59d99e92bdd", + "eventId": "71645612-3816-4813-bba4-68e5129257e4", + "deliveryId": "06934dfe-a970-4a9e-9af5-d54d28e61b07", "attempts": 1, "status": "delivered", "eventType": "entitlement.revoked" @@ -753,15 +857,15 @@ "operation": "webhook: expiry + revocation", "body": [ { - "eventId": "59fb04a6-cc0c-483a-b8ce-6b19e1730473", - "deliveryId": "e19fbfe7-93a1-4705-9ff5-fe325d113674", + "eventId": "69e93b80-aa53-4223-b50a-dff178f13d35", + "deliveryId": "1a5ecc7b-b7ce-44f6-bb0f-6c68cea5b9c1", "httpStatus": 200, "attempt": 1, "status": "delivered" }, { - "eventId": "f51af7b5-ee42-4c47-a91b-80c6bd94a39d", - "deliveryId": "d2304ba1-d8bf-4bbb-bfa8-f59d99e92bdd", + "eventId": "71645612-3816-4813-bba4-68e5129257e4", + "deliveryId": "06934dfe-a970-4a9e-9af5-d54d28e61b07", "httpStatus": 200, "attempt": 1, "status": "delivered" @@ -793,6 +897,84 @@ "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": "ef622c53-d681-4504-ab1f-ab93851a83d9", + "status": "completed" + } + }, + { + "operation": "eraseUser", + "httpStatus": 202, + "body": { + "accepted": true, + "jobId": "ef622c53-d681-4504-ab1f-ab93851a83d9", + "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", diff --git a/docs/build/07-account-erasure/screen.png b/docs/build/07-account-erasure/screen.png new file mode 100644 index 0000000..982e657 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..d07ccc5 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..a0299d0 100644 --- a/docs/build/guide.json +++ b/docs/build/guide.json @@ -38,7 +38,14 @@ "request": "Close access at expiry and review recovery and failure handling across the complete backend.", "change": "Completed expiry and recovery, then applied the external CLI review: atomic first-binding grant events, conflicting-expiry rejection, and source/patch verification.", "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", + "id": "06-recover", "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", + "label": "Erase" } ] diff --git a/docs/build/verification.json b/docs/build/verification.json index 2eab99c..f2dd924 100644 --- a/docs/build/verification.json +++ b/docs/build/verification.json @@ -1,18 +1,18 @@ { - "recordedAt": "2026-09-07T18:20:43.843Z", + "recordedAt": "2026-09-16T00:54:19.556Z", "scope": "Each recorded archive extracted outside both repositories; patches applied in order from an empty directory; published dependencies installed with npm.", "results": [ { "id": "01-contract", - "archiveSha256": "c9ce57ca1033edcf75c9e676f7756c5158d4adbeeb5dce3eac04890098c987e3", - "patchSha256": "b1b6f603f2fed0e65f990cb072f022570b5d9f22b3e3b97eb9a55182323d0762", + "archiveSha256": "be354098298d8fbbe9c72afcce1405f15a23fae95db8788cb1b5f71dd9319b8a", + "patchSha256": "72580caa82a312387ca0cf864374adbd4214f94fcf5383694b150a91d2977220", "sourceHashesMatch": true, "patchAppliesExactly": true, "commands": [ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 375ms\n" + "output": "\nadded 10 packages in 223ms\n" }, { "command": "npm test", @@ -23,15 +23,15 @@ }, { "id": "02-verify", - "archiveSha256": "ba06e8492b3e3e2972e5eb1c1c6e721771a032268e644837b8a084f4432467b4", - "patchSha256": "192c76de95d2f9c21280970b3a51e2e10ce530368681587b92b3cc82b914e267", + "archiveSha256": "5df992e5f1f8acc4d323a24a25249834c761183bfe19887cdffd4946c565dbd6", + "patchSha256": "f29b8ff88e813afe2ba18ad42c1fdcfd0e923b3422a064df133c19b19910c215", "sourceHashesMatch": true, "patchAppliesExactly": true, "commands": [ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 362ms\n" + "output": "\nadded 10 packages in 201ms\n" }, { "command": "npm test", @@ -42,15 +42,15 @@ }, { "id": "03-bind", - "archiveSha256": "fc5a10c937d14af61d117f9257b4fa3e5d95c143027ea935b1d338472165cb9f", - "patchSha256": "c9edf827f84065af730426bd26e2e83d3ae5cca29684db98e7fcd12df65134b3", + "archiveSha256": "880ce127299922ca5d3e97448026a26a6ed003936b2a0bfc097a734f79a1fae5", + "patchSha256": "2629932860947e3b6ff043d5587a7d6c48c309023b0158e178c33ea255fce4e1", "sourceHashesMatch": true, "patchAppliesExactly": true, "commands": [ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 274ms\n" + "output": "\nadded 10 packages in 209ms\n" }, { "command": "npm test", @@ -61,15 +61,15 @@ }, { "id": "04-cancel", - "archiveSha256": "95d8f609f6f5d1a0b924fdec258dc5469d60b99305359b4a3847579494e55ae5", - "patchSha256": "8b212cf47f4e539f7b933eaaa0be7f7f3b16d708377fa28d46c76b283c3ec7ea", + "archiveSha256": "519232143ccd860aed14eb95d22eb94dc31ad19a01943534fc6c64466cc29b7e", + "patchSha256": "fd7cec76566fb0174fd16a278e6f7d6dfb215be1c361891dd0205d7d7db673e7", "sourceHashesMatch": true, "patchAppliesExactly": true, "commands": [ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 254ms\n" + "output": "\nadded 10 packages in 207ms\n" }, { "command": "npm test", @@ -80,15 +80,15 @@ }, { "id": "05-deliver", - "archiveSha256": "a1d954f735fa37dd4577301ea684f6f407f596a4924c04667390dfc8749d6594", - "patchSha256": "b341cad097b2de24142f31f0818b4048078d84181f46204d1b755b31c346e46a", + "archiveSha256": "fa4c92d17f964094f7bc1e14b3f5db848f4e7eaea5cecf7740dfd2e53e3b9bab", + "patchSha256": "b8afbe48d79ad60912a8b968e0435866ea2695ad296414a3a4beb26a3b882c43", "sourceHashesMatch": true, "patchAppliesExactly": true, "commands": [ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 265ms\n" + "output": "\nadded 10 packages in 209ms\n" }, { "command": "npm test", @@ -99,207 +99,49 @@ }, { "id": "06-recover", - "archiveSha256": "4473472cf05d8ddb2b10b29a45bf60587c9a8b4a585687cd32dd6ce2302a0896", - "patchSha256": "b22e63e832f1b486b80b6a7d88ca9d7b0af7a781e6495a045fce060a04ac7e95", + "archiveSha256": "67bc01ef56eb04d677db3aff28ef3bf850a2838ab18e7ffcdcfe9709459c4a7f", + "patchSha256": "ca3cca8d4e314424305d2b8020df76c3a533ac4100318495006a8c263367d347", "sourceHashesMatch": true, "patchAppliesExactly": true, "commands": [ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 269ms\n" + "output": "\nadded 10 packages in 211ms\n" }, { "command": "npm test", "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs\n\nCommerce Lab: 82 checks passed. No store or production service contacted.\n" - } - ] - }, - { - "id": "06-recover-reviewed", - "archiveSha256": "1c1bc0b5f9d96f30b73338897ece812af0c8289fb38448b17b9ca91897af6a91", - "patchSha256": "cb4cc37b1b07fd18e829e55268c59ee2fb851cb6d02de613211d5938647e5d21", - "sourceHashesMatch": true, - "patchAppliesExactly": true, - "commands": [ - { - "command": "npm ci --ignore-scripts --no-audit --no-fund", - "exitCode": 0, - "output": "\nadded 10 packages in 267ms\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" - } - ] - }, - { - "id": "06-recover-reviewed-2", - "archiveSha256": "e96ffe60977c4f03060af0f6b65ecdd2211624d3b8a5bd124723b704b5ee9d8f", - "patchSha256": "e3e7d92df5959f847cd4bf4eec477573c934bcf321f713622a03f0f50ecab5a7", - "sourceHashesMatch": true, - "patchAppliesExactly": true, - "commands": [ - { - "command": "npm ci --ignore-scripts --no-audit --no-fund", - "exitCode": 0, - "output": "\nadded 10 packages in 294ms\n" - }, - { - "command": "npm test", - "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs\n\nCommerce Lab: 118 checks passed. No store or production service contacted.\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) 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" - } - ] - }, - { - "id": "06-recover-reviewed-3", - "archiveSha256": "5e160382f94bcadd81ca5f86943c9220ca053aafe58e44dd4770423cdb7c0453", - "patchSha256": "3e05ce664ae789f68fea40b9bbc990ce116aa5a35e21f238080194ba29f4f510", - "sourceHashesMatch": true, - "patchAppliesExactly": true, - "commands": [ - { - "command": "npm ci --ignore-scripts --no-audit --no-fund", - "exitCode": 0, - "output": "\nadded 10 packages in 272ms\n" - }, - { - "command": "npm test", - "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs\n\nCommerce Lab: 130 checks passed. No store or production service contacted.\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) 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" - } - ] - }, - { - "id": "06-recover-reviewed-4", - "archiveSha256": "afcd9c10edb66d2bd59dd183f1222b075b983efd6c6e7082ca6d4621e35143b4", - "patchSha256": "5ca6acbd146918279d8bc4d2e5fc70c79a58e2637c24c9527b32eccc34c29a31", - "sourceHashesMatch": true, - "patchAppliesExactly": true, - "commands": [ - { - "command": "npm ci --ignore-scripts --no-audit --no-fund", - "exitCode": 0, - "output": "\nadded 10 packages in 262ms\n" - }, - { - "command": "npm test", - "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs\n\nCommerce Protocol Example: 133 checks passed. No store or production service contacted.\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) 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" - } - ] - }, - { - "id": "06-recover-reviewed-5", - "archiveSha256": "8f0b26fb76cc5a129795b7f278dae61d365c25eb936cbe10f0b0233faee1a727", - "patchSha256": "4c63e82ff783aaec5cd0c6c9c421181cb1faa164227b5c7d2aba91c5a1e96be3", - "sourceHashesMatch": true, - "patchAppliesExactly": true, - "commands": [ - { - "command": "npm ci --ignore-scripts --no-audit --no-fund", - "exitCode": 0, - "output": "\nadded 10 packages in 273ms\n" - }, - { - "command": "npm test", - "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs\n\nCommerce Protocol Example: 133 checks passed. No store or production service contacted.\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) 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" - } - ] - }, - { - "id": "06-recover-reviewed-6", - "archiveSha256": "21d588909482cba125ea6f3688ace78111ce852c526374289c52e286aff86b15", - "patchSha256": "25d08ad1c312f933db3228453744b5f1d965e4296e1a9f6a39e77458545eabad", - "sourceHashesMatch": true, - "patchAppliesExactly": true, - "commands": [ - { - "command": "npm ci --ignore-scripts --no-audit --no-fund", - "exitCode": 0, - "output": "\nadded 10 packages in 269ms\n" - }, - { - "command": "npm test", - "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs\n\nCommerce Protocol Example: 133 checks passed. No store or production service contacted.\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.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" - } - ] - }, - { - "id": "06-recover-reviewed-7", - "archiveSha256": "9056f8a62e92bb3717a06e260652d6aaeb01aba61035f430a8dda8053fd914f1", - "patchSha256": "7c32d8b00dced1d0d4d632ebc5f34476f9b3932bea7aa7b497d3cc9ceff52820", - "sourceHashesMatch": true, - "patchAppliesExactly": true, - "commands": [ - { - "command": "npm ci --ignore-scripts --no-audit --no-fund", - "exitCode": 0, - "output": "\nadded 10 packages in 276ms\n" - }, - { - "command": "npm test", - "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs\n\nCommerce Protocol Example: 133 checks passed. No store or production service contacted.\n" + "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs\n\nCommerce Protocol Example: 138 checks passed. No store or production service contacted.\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.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\n 4 pass\n 0 fail\nRan 4 tests across 1 file. [66.00ms]\n" } ] }, { - "id": "06-recover-reviewed-8", - "archiveSha256": "868987cf443b86d76bc53c67c52fec4e44c43afdfc5eb16132284a120ef15cb1", - "patchSha256": "a525ca069f54ad0810484d7525a5ac781c6bd5c43a00f2e06013e85089fd4d4b", + "id": "07-account-erasure", + "archiveSha256": "2b8ba06ec7738efaf4039bc91c671ccb693da064e307bb1318782dfe70b19408", + "patchSha256": "b038536c24fb2b961ecaf3780212421e26935d0890a3a462e034437749ed7002", "sourceHashesMatch": true, "patchAppliesExactly": true, "commands": [ { "command": "npm ci --ignore-scripts --no-audit --no-fund", "exitCode": 0, - "output": "\nadded 10 packages in 295ms\n" + "output": "\nadded 10 packages in 206ms\n" }, { "command": "npm test", "exitCode": 0, - "output": "\n> openiap-commerce-protocol-example@0.0.0 test\n> bun verify.mjs\n\nCommerce Protocol Example: 138 checks passed. No store or production service contacted.\n" + "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\n 3 pass\n 0 fail\n 20 expect() calls\nRan 3 tests across 2 files. [138.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.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\n 5 pass\n 0 fail\nRan 5 tests across 1 file. [66.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-lock.json b/package-lock.json index b4979f7..6ac385f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,19 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "ajv": "^8.17.1", - "openiap-commerce-protocol": "0.1.0" + "@hyodotdev/openiap-commerce-protocol": "0.3.0", + "ajv": "^8.17.1" }, "devDependencies": { "@playwright/test": "1.62.1" } }, + "node_modules/@hyodotdev/openiap-commerce-protocol": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@hyodotdev/openiap-commerce-protocol/-/openiap-commerce-protocol-0.3.0.tgz", + "integrity": "sha512-IHm1uewLOrIIyud8wG2YQFd6YplUQIMMCAV68BmNOOVRKQBgRjr/HANLXZAChyK33MHgn0LKVoLxO6ukJQi+Ig==", + "license": "MIT" + }, "node_modules/@playwright/test": { "version": "1.62.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", @@ -91,12 +97,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/openiap-commerce-protocol": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/openiap-commerce-protocol/-/openiap-commerce-protocol-0.1.0.tgz", - "integrity": "sha512-z92P3JeMK0Hj2y227ibDYKz1qJJC0JuwRUqm2mi6slhQoBHYTldXYIExHDtl7Q3SbqGmPqpBsTWlTwVRFymSjA==", - "license": "MIT" - }, "node_modules/playwright": { "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", diff --git a/package.json b/package.json index 36f1f41..268ccac 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", @@ -14,7 +14,7 @@ "demo:bridge": "bun client-bridge.mjs" }, "dependencies": { - "openiap-commerce-protocol": "0.1.0", + "@hyodotdev/openiap-commerce-protocol": "0.3.0", "ajv": "^8.17.1" }, "devDependencies": { diff --git a/provider.mjs b/provider.mjs index 643c03c..4b0c2e5 100644 --- a/provider.mjs +++ b/provider.mjs @@ -4,8 +4,9 @@ import { providerCapabilitiesSchema, COMMERCE_EVENT_VERSION, HTTP_BINDING, -} from "openiap-commerce-protocol"; +} from "@hyodotdev/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); @@ -98,18 +149,24 @@ export function createProvider(path, now) { "expiration", ]); const capabilities = { - specVersion: HTTP_BINDING.protocolVersion, + commerceProtocolVersion: HTTP_BINDING.protocolVersion, implementation: { name: "Commerce Protocol Example — fictional fixture store", }, 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..d8ef01e --- /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 "@hyodotdev/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..92fc76b 100644 --- a/verify.mjs +++ b/verify.mjs @@ -1,13 +1,15 @@ import assert from "node:assert/strict"; import { rmSync } from "node:fs"; -import vectors from "openiap-commerce-protocol/vectors/signatures.json"; -import { SUBSCRIPTION_STATES, WEBHOOK } from "openiap-commerce-protocol"; +import vectors from "@hyodotdev/openiap-commerce-protocol/vectors/signatures.json"; +import { SUBSCRIPTION_STATES, WEBHOOK } from "@hyodotdev/openiap-commerce-protocol"; import { FIXTURE, isEntitled } from "./provider.mjs"; import { requestOperation } from "./scenario.mjs"; 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..df8523d 100644 --- a/webhooks.mjs +++ b/webhooks.mjs @@ -1,7 +1,8 @@ import { Database } from "bun:sqlite"; import { createHmac, timingSafeEqual } from "node:crypto"; -import { WEBHOOK } from "openiap-commerce-protocol"; +import { WEBHOOK } from "@hyodotdev/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,