← Provider replacement walkthrough

Commerce Protocol Example · verify.mjs

Source snapshot · 2026-09-09

SHA-256 d3bfb64cb50672f9671162d28c82c961b7b4ef65ede147e6653d5c41204ff8b2
1import assert from "node:assert/strict";2import { rmSync } from "node:fs";3import vectors from "openiap-commerce-protocol/vectors/signatures.json";4import { SUBSCRIPTION_STATES, WEBHOOK } from "openiap-commerce-protocol";5import { FIXTURE, isEntitled } from "./provider.mjs";6import { requestOperation } from "./scenario.mjs";7import { runConsumerDemo } from "./consumer.mjs";8import { runBridgeDemo } from "./client-bridge.mjs";9import { startLab } from "./server.mjs";10import { authentic, createReceiver, deliver, sign } from "./webhooks.mjs";11import { verifyStores } from "./verify-stores.mjs";12import { verifyErasure } from "./verify-erasure.mjs";13 14export async function verifyLab({ compareSigner } = {}) {15  const lab = startLab();16  const checks = [];17  const check = (label, actual, expected) => {18    assert.deepEqual(actual, expected, label);19    checks.push(label);20  };21  try {22    for (let i = 0; i < 6; i++) await lab.scenario.advance();23    checks.push(...lab.scenario.history.flatMap((entry) => entry.checks));24    for (const vector of vectors.cases) {25      const signature = sign(vector.secret, vector.timestamp, vector.body);26      const expected = vector.previousSecret27        ? `${signature},${sign(vector.previousSecret, vector.timestamp, vector.body)}`28        : signature;29      check(`Signature vector: ${vector.name}`, expected, vector.expected);30      if (compareSigner) {31        check(32          `IAPKit signs the same bytes: ${vector.name}`,33          signature,34          await compareSigner(vector.secret, vector.timestamp, vector.body),35        );36      }37      check(38        `Receiver accepts vector: ${vector.name}`,39        authentic(40          [vector.secret],41          String(vector.timestamp),42          vector.body,43          vector.presentedHeader ?? expected,44          vector.timestamp,45        ),46        true,47      );48    }49    for (const vector of vectors.rejections) {50      check(51        `Receiver rejects: ${vector.name}`,52        authentic(53          [vector.secret],54          String(vector.timestamp),55          vector.body,56          vector.presentedSignature,57          vector.receiverNow ?? vector.timestamp,58        ),59        false,60      );61    }62    for (const state of [...SUBSCRIPTION_STATES, "FutureState"]) {63      const eligible = state === "Active" || state === "InGracePeriod";64      check(`${state}: before expiry`, isEntitled(state, 100, 99), eligible);65      check(`${state}: at expiry`, isEntitled(state, 100, 100), false);66      check(67        `${state}: no deadline`,68        isEntitled(state, undefined, 100),69        eligible,70      );71    }72    const call = (name, input, role) =>73      requestOperation(lab.runtime.baseUrl, name, input, role);74    check(75      "Missing credentials are refused",76      (await call("entitlements", { userId: FIXTURE.userId }, null)).httpStatus,77      401,78    );79    check(80      "Verification role cannot enumerate users",81      (await call("entitlements", { userId: FIXTURE.userId }, "verification"))82        .httpStatus,83      403,84    );85    check(86      "Malformed input is refused",87      (await call("bindPurchase", { store: FIXTURE.store })).httpStatus,88      400,89    );90    check(91      "A real store is not falsely accepted",92      (93        await call("verifyPurchase", {94          store: "google",95          google: { purchaseToken: "fixture" },96        })97      ).body.error.code,98      "UNSUPPORTED_STORE",99    );100    check(101      "Erasure rejects verification credentials",102      (await call("eraseUser", { userId: FIXTURE.userId }, "verification"))103        .httpStatus,104      403,105    );106    check(107      "Cancellation after expiry is ignored",108      lab.runtime.provider.observe({109        id: "late-cancel",110        kind: "cancel",111        occurredAt: FIXTURE.expiresAt,112      }),113      false,114    );115    const expiredVerdict = await call("verifyPurchase", {116      store: FIXTURE.store,117      evidence: FIXTURE.evidence,118    });119    check(120      "Expired fixture evidence has an expired verdict",121      [expiredVerdict.body.isValid, expiredVerdict.body.state],122      [false, "EXPIRED"],123    );124    const body = lab.runtime.provider.db125      .query("SELECT body FROM outbox LIMIT 1")126      .get().body;127    const timestamp = Math.floor(lab.runtime.time / 1000).toString();128    const invalid = await lab.runtime.receiver.fetch(129      new Request(`${lab.runtime.baseUrl}/demo/receiver`, {130        method: "POST",131        body: body + " ",132        headers: {133          [WEBHOOK.timestampHeader]: timestamp,134          [WEBHOOK.signatureHeader]: sign(lab.runtime.secret, timestamp, body),135        },136      }),137    );138    check("Tampered HTTP body has no inbox effect", invalid.status, 401);139    const byteReceiver = createReceiver(140      ":memory:",141      lab.runtime.secret,142      () => lab.runtime.time,143    );144    try {145      const event = { ...JSON.parse(body), userId: "demo_\uFFFD" };146      const unicode = Buffer.from(JSON.stringify(event));147      const offset = unicode.indexOf(Buffer.from("\uFFFD"));148      const malformed = Buffer.concat([149        unicode.subarray(0, offset),150        Buffer.from([0xff]),151        unicode.subarray(offset + 3),152      ]);153      const withBom = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), unicode]);154      const send = (bytes, signedBytes = unicode) =>155        byteReceiver.fetch(156          new Request(`${lab.runtime.baseUrl}/demo/receiver`, {157            method: "POST",158            body: bytes,159            headers: {160              [WEBHOOK.timestampHeader]: timestamp,161              [WEBHOOK.signatureHeader]: sign(162                lab.runtime.secret,163                timestamp,164                signedBytes,165              ),166              [WEBHOOK.eventIdHeader]: event.eventId,167            },168          }),169        );170      for (const [label, bytes] of [171        ["Changed UTF-8 bytes", malformed],172        ["Inserted UTF-8 BOM", withBom],173      ]) {174        check(175          `${label} cannot reuse a signature`,176          [(await send(bytes)).status, byteReceiver.count()],177          [401, 0],178        );179      }180      check(181        "Authenticated malformed UTF-8 is rejected before storage",182        [(await send(malformed, malformed)).status, byteReceiver.count()],183        [400, 0],184      );185      check(186        "Authentic Unicode bytes are accepted and stored",187        [(await send(unicode)).status, byteReceiver.count()],188        [200, 1],189      );190      check(191        "Authentic BOM bytes are verified before decoding",192        [(await send(withBom, withBom)).status, byteReceiver.count()],193        [200, 1],194      );195    } finally {196      byteReceiver.close();197    }198    check(199      "Receiver still has exactly four events",200      lab.runtime.receiver.count(),201      4,202    );203    check(204      "Cross-origin demo mutations are refused",205      (206        await fetch(`${lab.runtime.baseUrl}/demo/advance`, {207          method: "POST",208          headers: { origin: "https://untrusted.example" },209        })210      ).status,211      403,212    );213  } finally {214    await lab.close();215    rmSync(lab.directory, { recursive: true, force: true });216  }217 218  const fresh = startLab();219  try {220    const call = (name, input) =>221      requestOperation(fresh.runtime.baseUrl, name, input);222    const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence };223    await call("verifyPurchase", evidence);224    const bindings = await Promise.all(225      ["alice", "bob"].map((userId) =>226        call("bindPurchase", { ...evidence, userId }),227      ),228    );229    check(230      "Overlapping HTTP ownership claims have one winner",231      bindings.filter((row) => row.body.bound).length,232      1,233    );234    check(235      "A cancellation older than the active row is ignored",236      fresh.runtime.provider.observe({237        id: "old-active-cancel",238        kind: "cancel",239        occurredAt: FIXTURE.startsAt - 1,240      }),241      false,242    );243    check(244      "Ignoring an old cancellation preserves renewal",245      fresh.runtime.provider.inspect().purchases[0].willRenew,246      1,247    );248    assert.throws(249      () =>250        fresh.runtime.provider.observe({251          id: "early-expiry",252          kind: "expire",253          occurredAt: FIXTURE.startsAt,254        }),255      /reconciliation/,256    );257    check(258      "Conflicting expiry is not consumed",259      fresh.runtime.provider.db260        .query(261          "SELECT COUNT(*) AS count FROM observations WHERE id = 'early-expiry'",262        )263        .get().count,264      0,265    );266    fresh.runtime.time += 1000;267    const observation = {268      id: "atomic-cancel",269      kind: "cancel",270      occurredAt: fresh.runtime.time,271    };272    fresh.runtime.provider.db.exec(273      "CREATE TRIGGER fail_outbox BEFORE INSERT ON outbox BEGIN SELECT RAISE(ABORT, 'injected disk failure'); END;",274    );275    assert.throws(() => fresh.runtime.provider.observe(observation));276    check(277      "Outbox failure rolls back subscription state",278      fresh.runtime.provider.inspect().purchases[0].willRenew,279      1,280    );281    fresh.runtime.provider.db.exec("DROP TRIGGER fail_outbox");282    check(283      "Failed transaction leaves the observation retryable",284      fresh.runtime.provider.observe(observation),285      true,286    );287    for (let attempt = 0; attempt < 3; attempt++) {288      fresh.runtime.time += 120_000;289      await deliver(290        fresh.runtime.provider,291        fresh.runtime.secret,292        fresh.runtime.now,293        async () => new Response(null, { status: 503 }),294      );295    }296    check(297      "Exhausted retries enter dead-letter",298      fresh.runtime.provider.inspect().deliveries[0].status,299      "dead-letter",300    );301  } finally {302    await fresh.close();303    rmSync(fresh.directory, { recursive: true, force: true });304  }305  const binding = startLab();306  try {307    const call = (name, input) =>308      requestOperation(binding.runtime.baseUrl, name, input);309    const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence };310    await call("verifyPurchase", evidence);311    binding.runtime.provider.db.exec(312      "CREATE TRIGGER fail_grant BEFORE INSERT ON outbox BEGIN SELECT RAISE(ABORT, 'injected disk failure'); END;",313    );314    check(315      "Grant failure rejects binding",316      (await call("bindPurchase", { ...evidence, userId: FIXTURE.userId }))317        .httpStatus,318      500,319    );320    check(321      "Grant failure rolls back ownership",322      binding.runtime.provider.inspect().purchases[0].userId,323      null,324    );325    binding.runtime.provider.db.exec("DROP TRIGGER fail_grant");326    binding.runtime.time = FIXTURE.expiresAt;327    check(328      "An expired purchase can be bound",329      (await call("bindPurchase", { ...evidence, userId: FIXTURE.userId })).body330        .bound,331      true,332    );333    check(334      "Binding expired evidence emits no grant",335      binding.runtime.provider.inspect().deliveries,336      [],337    );338  } finally {339    await binding.close();340    rmSync(binding.directory, { recursive: true, force: true });341  }342  const delayed = startLab();343  try {344    const call = (name, input) =>345      requestOperation(delayed.runtime.baseUrl, name, input);346    const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence };347    await call("verifyPurchase", evidence);348    delayed.runtime.time += 3_600_000;349    await call("bindPurchase", { ...evidence, userId: FIXTURE.userId });350    const grant = JSON.parse(351      delayed.runtime.provider.db.query("SELECT body FROM outbox LIMIT 1").get()352        .body,353    );354    check(355      "Delayed binding retains the store occurrence",356      grant.occurredAt,357      FIXTURE.startsAt,358    );359    check(360      "Delayed binding records its actual processing time",361      grant.processedAt,362      delayed.runtime.time,363    );364    delayed.runtime.restart();365    delayed.runtime.time = FIXTURE.expiresAt + 60_000;366    delayed.runtime.provider.observe({367      id: "late-expiry",368      kind: "expire",369      occurredAt: delayed.runtime.time,370    });371    check(372      "Late expiry revokes a persisted grant exactly once",373      delayed.runtime.provider.inspect().deliveries.map((row) => row.eventType),374      ["entitlement.granted", "subscription.expired", "entitlement.revoked"],375    );376    delayed.runtime.provider.observe({377      id: "another-expiry",378      kind: "expire",379      occurredAt: delayed.runtime.time,380    });381    check(382      "Repeated expiry emits no second revocation",383      delayed.runtime.provider.inspect().deliveries.length,384      3,385    );386  } finally {387    await delayed.close();388    rmSync(delayed.directory, { recursive: true, force: true });389  }390  const equalTime = startLab();391  try {392    const call = (name, input) =>393      requestOperation(equalTime.runtime.baseUrl, name, input);394    const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence };395    await call("verifyPurchase", evidence);396    await call("bindPurchase", { ...evidence, userId: FIXTURE.userId });397    equalTime.runtime.time = FIXTURE.expiresAt;398    equalTime.runtime.provider.observe({399      id: "cancel-at-deadline",400      kind: "cancel",401      occurredAt: FIXTURE.expiresAt,402    });403    equalTime.runtime.provider.observe({404      id: "expire-at-deadline",405      kind: "expire",406      occurredAt: FIXTURE.expiresAt,407    });408    check(409      "Equal-time expiry retains its lifecycle transition",410      equalTime.runtime.provider.inspect().purchases[0].state,411      "Expired",412    );413    check(414      "Equal-time observations revoke once and preserve both transitions",415      equalTime.runtime.provider416        .inspect()417        .deliveries.map((row) => row.eventType),418      [419        "entitlement.granted",420        "subscription.canceled",421        "entitlement.revoked",422        "subscription.expired",423      ],424    );425  } finally {426    await equalTime.close();427    rmSync(equalTime.directory, { recursive: true, force: true });428  }429  checks.push(...(await runConsumerDemo()).checks);430  checks.push(...runBridgeDemo());431  checks.push(...(await verifyStores()));432  checks.push(...(await verifyErasure()));433  return checks;434}435 436if (import.meta.main) {437  const checks = await verifyLab();438  console.log(439    `Commerce Protocol Example: ${checks.length} checks passed. No store or production service contacted.`,440  );441}442