← Provider replacement walkthrough

Commerce Protocol Example · scenario.mjs

Source snapshot · 2026-09-09

SHA-256 b8ff9024afd67701b5f70916a61fa7d538eaec281aa4b5cc660e1c205932a32d
1import assert from "node:assert/strict";2import { operation, validate } from "./contract.mjs";3import { CREDENTIALS, FIXTURE } from "./provider.mjs";4import { deliver } from "./webhooks.mjs";5 6export const STAGES = [7  {8    title: "Start with the contract",9    built: "HTTP routes + schema validation + SQLite",10    result: "A running server, an empty purchase table, and no access.",11  },12  {13    title: "Verify a purchase",14    built: "Fixture store adapter + purchase persistence",15    result: "Valid evidence is saved. Alice still has no access.",16  },17  {18    title: "Connect it to a user",19    built: "Server authorization + atomic binding + entitlement reads",20    result: "Alice gets Premium. Another user cannot take the purchase.",21  },22  {23    title: "Handle cancellation",24    built: "Lifecycle processing + transactional event outbox",25    result: "Renewal stops. Alice keeps the time she already paid for.",26  },27  {28    title: "Deliver, retry, deduplicate",29    built: "HMAC signatures + retry worker + durable receiver inbox",30    result:31      "A 503 retries successfully. Redelivery creates no second inbox row.",32  },33  {34    title: "Expire access and restart",35    built: "Expiry-aware reads + recovery from SQLite",36    result:37      "Access closes at the deadline. Restarting preserves purchases and deliveries.",38  },39  {40    title: "Delete the account",41    built: "Idempotent erasure + receiver cleanup + durable deletion guard",42    result:43      "Alice is removed from purchases and event copies. Late deliveries cannot restore her account data.",44  },45];46 47export async function requestOperation(baseUrl, name, input, role = "server") {48  const spec = operation(name);49  const url = new URL(spec.path, baseUrl);50  if (spec.method === "GET" && input)51    for (const [key, value] of Object.entries(input))52      url.searchParams.set(key, value);53  const response = await fetch(url, {54    method: spec.method,55    headers: {56      "content-type": "application/json",57      ...(role ? { authorization: CREDENTIALS[role] } : {}),58    },59    ...(spec.method === "POST" ? { body: JSON.stringify(input) } : {}),60  });61  return {62    operation: name,63    httpStatus: response.status,64    body: await response.json(),65  };66}67 68export function createScenario(runtime) {69  const history = [];70  const checks = [];71  const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence };72  const call = (name, input, role) =>73    requestOperation(runtime.baseUrl, name, input, role);74  const check = (label, actual, expected) => {75    assert.deepEqual(actual, expected, label);76    checks.push(label);77  };78 79  async function advance() {80    const stage = history.length;81    if (stage >= STAGES.length) return history.at(-1);82    const responses = [];83    const run = async (name, input, role) => {84      const result = await call(name, input, role);85      responses.push(result);86      return result;87    };88    const start = checks.length;89    if (stage === 0) {90      check(91        "Fixture request matches the installed schema",92        validate(operation("verifyPurchase").input, evidence),93        true,94      );95      check(96        "Capabilities use the published response schema",97        (await run("providerCapabilities", null, null)).httpStatus,98        200,99      );100      check(101        "Purchase storage starts empty",102        runtime.provider.inspect().purchases,103        [],104      );105      check(106        "No profile conformance is claimed",107        (await run("providerCapabilities", null, null)).body.profiles,108        undefined,109      );110    } else if (stage === 1) {111      check(112        "Fixture evidence is accepted",113        (await run("verifyPurchase", evidence, "verification")).body.isValid,114        true,115      );116      check(117        "Verification does not grant access",118        (await run("entitlements", { userId: FIXTURE.userId })).body.productIds,119        [],120      );121      check(122        "Invalid evidence produces a negative verdict",123        (124          await run(125            "verifyPurchase",126            { store: FIXTURE.store, evidence: "invalid" },127            "verification",128          )129        ).body.isValid,130        false,131      );132      check(133        "An upstream outage is not a negative verdict",134        (135          await run(136            "verifyPurchase",137            { store: FIXTURE.store, evidence: "local-upstream-outage" },138            "verification",139          )140        ).body.error.code,141        "VERIFICATION_FAILED",142      );143    } else if (stage === 2) {144      check(145        "Verification credentials cannot bind a user",146        (147          await run(148            "bindPurchase",149            { ...evidence, userId: FIXTURE.userId },150            "verification",151          )152        ).body.error.code,153        "FORBIDDEN",154      );155      check(156        "The server binds Alice",157        (await run("bindPurchase", { ...evidence, userId: FIXTURE.userId }))158          .body.bound,159        true,160      );161      check(162        "Repeating the same binding succeeds",163        (await run("bindPurchase", { ...evidence, userId: FIXTURE.userId }))164          .body.bound,165        true,166      );167      check(168        "Bob cannot take Alice's purchase",169        (await run("bindPurchase", { ...evidence, userId: "demo_bob" })).body170          .bound,171        false,172      );173      check(174        "Alice can access Premium",175        (await run("entitlements", { userId: FIXTURE.userId })).body.productIds,176        [FIXTURE.productId],177      );178      check(179        "First binding queues one grant; repeat and conflict queue none",180        runtime.provider.inspect().deliveries.map((row) => row.eventType),181        ["entitlement.granted"],182      );183    } else if (stage === 3) {184      runtime.time = FIXTURE.startsAt + 86_400_000;185      runtime.provider.observe({186        id: "fixture-cancel",187        kind: "cancel",188        occurredAt: runtime.time,189      });190      const result = await run("subscriptionStatus", {191        userId: FIXTURE.userId,192      });193      check("Cancellation keeps paid access", result.body.active, true);194      check(195        "Cancellation stops renewal",196        result.body.subscription.willRenew,197        false,198      );199      check(200        "Cancellation queues one event",201        runtime.provider202          .inspect()203          .deliveries.filter((row) => row.eventType === "subscription.canceled")204          .length,205        1,206      );207    } else if (stage === 4) {208      const failed = await deliver(209        runtime.provider,210        runtime.secret,211        runtime.now,212        async () => new Response(null, { status: 503 }),213      );214      responses.push({215        operation: "webhook: receiver unavailable",216        body: failed,217      });218      check("A 503 leaves a durable retry", failed[0].status, "pending");219      runtime.restart();220      runtime.time += 30_000;221      const delivered = await deliver(222        runtime.provider,223        runtime.secret,224        runtime.now,225        runtime.post,226      );227      responses.push({228        operation: "webhook: retry after restart",229        body: delivered,230      });231      check("Retry survives provider restart", delivered[0].httpStatus, 200);232      check(233        "Retry keeps the delivery identity",234        delivered[0].deliveryId,235        failed[0].deliveryId,236      );237      // Simulate loss of the delivery acknowledgement after the receiver committed.238      runtime.provider.db239        .query("UPDATE outbox SET status = 'pending', next_at = 0")240        .run();241      runtime.time += 1_000;242      responses.push({243        operation: "webhook: lost-ack redelivery",244        body: await deliver(245          runtime.provider,246          runtime.secret,247          runtime.now,248          runtime.post,249        ),250      });251      check(252        "Redelivery has one inbox row per event",253        runtime.receiver.count(),254        2,255      );256    } else if (stage === 5) {257      runtime.time = FIXTURE.expiresAt;258      check(259        "Access closes at expiry before a notification arrives",260        (await run("entitlements", { userId: FIXTURE.userId })).body.productIds,261        [],262      );263      const observation = {264        id: "fixture-expire",265        kind: "expire",266        occurredAt: runtime.time,267      };268      runtime.provider.observe(observation);269      check(270        "Duplicate store notification emits no extra event",271        runtime.provider.observe(observation),272        false,273      );274      const before = runtime.provider.inspect();275      runtime.restart();276      check(277        "Reopening both databases preserves state",278        runtime.provider.inspect(),279        before,280      );281      check(282        "Receiver deduplication survives restart",283        runtime.receiver.count(),284        2,285      );286      responses.push({287        operation: "webhook: expiry + revocation",288        body: await deliver(289          runtime.provider,290          runtime.secret,291          runtime.now,292          runtime.post,293        ),294      });295      check("All four events reach the receiver", runtime.receiver.count(), 4);296      check(297        "The final status is inactive",298        (await run("subscriptionStatus", { userId: FIXTURE.userId })).body299          .active,300        false,301      );302    } else if (stage === 6) {303      check(304        "Verification credentials cannot erase users",305        (await run("eraseUser", { userId: FIXTURE.userId }, "verification"))306          .httpStatus,307        403,308      );309      // The app removes its event copies before requesting provider erasure.310      runtime.receiver.eraseUser(FIXTURE.userId);311      const erased = await run("eraseUser", { userId: FIXTURE.userId });312      check(313        "Provider erasure completes",314        [erased.body.accepted, erased.body.status],315        [true, "completed"],316      );317      runtime.restart();318      check(319        "Erasure retry after restart returns the same job",320        (await run("eraseUser", { userId: FIXTURE.userId })).body,321        erased.body,322      );323      check(324        "Purchase has no account identity",325        runtime.provider.inspect().purchases.map((row) => row.userId),326        [null],327      );328      check("App event copies are erased", runtime.receiver.count(), 0);329      check(330        "Erased account has no access",331        (await run("entitlements", { userId: FIXTURE.userId })).body.productIds,332        [],333      );334      check(335        "A stale binding retry cannot restore identity",336        (await run("bindPurchase", { ...evidence, userId: FIXTURE.userId }))337          .body.bound,338        false,339      );340    }341    const entry = {342      step: stage + 1,343      ...STAGES[stage],344      simulatedTime: new Date(runtime.time).toISOString(),345      ...runtime.provider.inspect(),346      inboxCount: runtime.receiver.count(),347      responses,348      checks: checks.slice(start),349      totalChecks: checks.length,350    };351    history.push(entry);352    return entry;353  }354 355  return { advance, history };356}357