← Provider replacement walkthrough

IAPKit · scripts/docs/run-commerce-interop.mjs

Source snapshot · 2026-10-21

SHA-256 8f2a3e38659bb35784bd182927121280a2dee2a73142f96d7855360f0bb0143c
1import assert from "node:assert/strict";2import { mock } from "bun:test";3import {4  existsSync,5  mkdirSync,6  mkdtempSync,7  readFileSync,8  rmSync,9  writeFileSync,10  symlinkSync,11} from "node:fs";12import { tmpdir } from "node:os";13import { join, resolve } from "node:path";14import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";15import { ConvexHttpClient } from "convex/browser";16import { anyApi, getFunctionName } from "convex/server";17import { Hono } from "hono";18import { inventories, pinSources } from "./commerce-source-snapshot.mjs";19 20// Real routes and their guarded action handlers share only this run's credential.21process.env.IAPKIT_VERIFICATION_SOURCE_SECRET = randomBytes(32).toString("hex");22 23const kit = resolve(import.meta.dir, "../..");24const repo = resolve(kit, "../..");25const convexVersion = JSON.parse(26  readFileSync(join(kit, "node_modules/convex/package.json"), "utf8"),27).version;28const example = resolve(29  process.argv[2] ?? join(repo, "../openiap-commerce-protocol-example"),30);31const freshExample = process.argv[4] ? resolve(process.argv[4]) : null;32const freshInputs = freshExample33  ? pinSources(freshExample, () => [34      ...new Bun.Glob("*.mjs").scanSync({ cwd: freshExample }),35      "package.json",36      "package-lock.json",37    ])38  : null;39const revision = (cwd) => {40  const git = (args) => {41    const result = Bun.spawnSync(["git", ...args], { cwd });42    assert.equal(result.exitCode, 0, "Cannot identify source revision");43    return result.stdout.toString().trim();44  };45  // Uncommitted changes are recorded as such, never attributed to HEAD.46  return (47    git(["rev-parse", "HEAD"]) +48    (git(["status", "--porcelain"]) ? "-dirty" : "")49  );50};51const revisions = { openiap: revision(repo), example: revision(example) };52const unchangedFiles = [53  "composition/app-backend.mjs",54  "composition/commerce-client.mjs",55  "consumer.mjs",56  "webhooks.mjs",57  "erasure.mjs",58  "contract.mjs",59];60const inputs = {61  openiap: pinSources(kit, () => inventories.openiap(kit)),62  example: pinSources(example, () => [63    ...unchangedFiles,64    ...new Bun.Glob("*.mjs").scanSync({ cwd: example }),65    ...new Bun.Glob("composition/**/*.mjs").scanSync({ cwd: example }),66    "README.md",67    "package.json",68    "package-lock.json",69  ]),70  harness: pinSources(import.meta.dir, () =>71    inventories.harness(import.meta.dir),72  ),73  workspace: pinSources(repo, () => inventories.workspace(repo)),74};75const before = Object.fromEntries(76  unchangedFiles.map((name) => [name, inputs.example.hashes[name]]),77);78function optionalGeneratedFiles(root, hashes) {79  const result = Bun.spawnSync(["git", "check-ignore", "--stdin"], {80    cwd: root,81    stdin: Buffer.from(Object.keys(hashes).join("\n") + "\n"),82  });83  assert([0, 1].includes(result.exitCode), "Cannot identify generated inputs");84  return result.stdout85    .toString()86    .trim()87    .split("\n")88    .filter((name) => /(^|\/)_?generated\//.test(name));89}90const optionalInputs = {91  openiap: optionalGeneratedFiles(kit, inputs.openiap.hashes),92  workspace: optionalGeneratedFiles(repo, inputs.workspace.hashes),93};94const output = resolve(95  process.argv[3] ?? mkdtempSync(join(tmpdir(), "commerce-interop-")),96);97const deployment = join(output, "local-convex");98mkdirSync(deployment, { recursive: true });99assert(100  !existsSync(join(deployment, ".env.local")),101  "Use a new output directory; a run must start empty.",102);103// Unrelated image processing and maintenance entry points stay out of the isolated deployment.104for (const name of Object.keys(inputs.openiap.hashes))105  if (106    (name.startsWith("convex/") ||107      ["convex.json", "tsconfig.json"].includes(name)) &&108    !["convex/crons.ts", "convex/files/action.ts"].includes(name)109  )110    inputs.openiap.write(name, join(deployment, name));111inputs.harness.write(112  "commerce-interop-fixture.ts",113  join(deployment, "convex/interopFixture.ts"),114);115symlinkSync(join(kit, "node_modules"), join(deployment, "node_modules"), "dir");116writeFileSync(117  join(deployment, "package.json"),118  JSON.stringify({119    name: "commerce-interop-local",120    type: "module",121    dependencies: { convex: convexVersion },122  }),123);124const env = { ...process.env };125for (const key of Object.keys(env))126  if (127    key.startsWith("CONVEX_") ||128    key.startsWith("VITE_KIT_CONVEX_") ||129    key === "IAPKIT_VERIFICATION_SOURCE_SECRET" ||130    key === "VITE_CONVEX_URL"131  )132    delete env[key];133env.CONVEX_AGENT_MODE = "anonymous";134const logPath = join(output, "convex.log");135let convexProcess;136async function boot() {137  writeFileSync(logPath, "");138  convexProcess = Bun.spawn(139    [140      process.execPath,141      join(kit, "node_modules/convex/bin/main.js"),142      "dev",143      "--typecheck",144      "disable",145      "--codegen",146      "disable",147      "--tail-logs",148      "disable",149      "--local-backend-version",150      "precompiled-2026-08-25-7cce8fb",151    ],152    {153      cwd: deployment,154      env,155      stdout: Bun.file(logPath),156      stderr: Bun.file(logPath),157    },158  );159  for (let i = 0; i < 600; i++) {160    if (readFileSync(logPath, "utf8").includes("Convex functions ready!"))161      return;162    if (convexProcess.exitCode !== null)163      throw new Error(`Local Convex exited; inspect ${logPath}`);164    await Bun.sleep(250);165  }166  throw new Error(`Local Convex did not become ready; inspect ${logPath}`);167}168async function stop() {169  if (convexProcess) {170    convexProcess.kill("SIGINT");171    await convexProcess.exited;172  }173}174const checks = [],175  trace = [],176  results = {177    example: { label: "openiap-commerce-protocol-example" },178    iapkit: { label: "IAPKit" },179  };180function check(label, actual, expected) {181  assert.deepEqual(actual, expected, label);182  checks.push(label);183  console.log(`PASS ${label}`);184}185const hash = (path) =>186  createHash("sha256").update(readFileSync(path)).digest("hex");187let appServer, receiver, sqlite, sqliteServer, kitServer;188try {189  await boot();190  const config = JSON.parse(191    readFileSync(join(deployment, ".convex/local/default/config.json"), "utf8"),192  );193  const convexUrl = `http://127.0.0.1:${config.ports.cloud}`;194  assert.equal(new URL(convexUrl).hostname, "127.0.0.1");195  process.env.VITE_KIT_CONVEX_URL = convexUrl;196  const admin = new ConvexHttpClient(convexUrl);197  admin.setAdminAuth(config.adminKey);198  const ctx = {199    runQuery: admin.query.bind(admin),200    runMutation: admin.mutation.bind(admin),201    runAction: admin.action.bind(admin),202  };203  const secret = randomBytes(32).toString("hex");204  const exampleSecret = randomBytes(32).toString("hex");205  const key = "openiap-kit_sk_" + randomBytes(24).toString("hex"),206    publishable = "openiap-kit_pk_" + randomBytes(24).toString("hex");207  // The Apple client is mocked and never signs, but verification still checks208  // that the stored key could.209  const { privateKey: appleKey } = generateKeyPairSync("ec", {210    namedCurve: "prime256v1",211    privateKeyEncoding: { type: "pkcs8", format: "pem" },212    publicKeyEncoding: { type: "spki", format: "pem" },213  });214  const seeded = await admin.action(anyApi.interopFixture.setup, {215    secret,216    key,217    publishable,218    appleKey,219  });220  const startedAt = Date.now();221  let expiresAt = startedAt + 45000,222    token = "local-interop-google-token",223    orderId = "GPA.local-fixture";224  const userId = "interop_alice",225    productId = "premium.monthly";226  let googleState = "SUBSCRIPTION_STATE_ACTIVE";227  let googleTestPurchase = true;228  mock.module("googleapis", () => ({229    google: {230      auth: { GoogleAuth: class {} },231      androidpublisher: () => ({232        purchases: {233          productsv2: {234            getproductpurchasev2: async () => {235              throw { code: 404 };236            },237          },238          subscriptionsv2: {239            get: async ({ token: received }) => {240              if (received !== token) throw { code: 404 };241              return {242                data: {243                  subscriptionState: googleState,244                  startTime: new Date(startedAt - 60000).toISOString(),245                  acknowledgementState: "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",246                  latestOrderId: orderId,247                  ...(googleTestPurchase ? { testPurchase: {} } : {}),248                  lineItems: [249                    {250                      productId,251                      expiryTime: new Date(expiresAt).toISOString(),252                      autoRenewingPlan: {253                        autoRenewEnabled:254                          googleState === "SUBSCRIPTION_STATE_ACTIVE",255                        recurringPrice: { currencyCode: "USD", units: "5" },256                      },257                    },258                  ],259                },260              };261            },262          },263        },264      }),265    },266  }));267  mock.module("google-auth-library", () => ({268    OAuth2Client: class {269      async verifyIdToken() {270        return {271          getPayload: () => ({272            email: "fixture@local-test.iam.gserviceaccount.com",273            email_verified: true,274          }),275        };276      }277    },278  }));279  process.env.GOOGLE_PUBSUB_AUDIENCE = "https://kit.example.com";280  const storeState = { apple: true, amazon: true, horizon: true };281  const amazonPurchaseDate = Date.now() - 60000;282  const applePayload = {283    transactionId: "90000000000001",284    originalTransactionId: "90000000000001",285    bundleId: "dev.openiap.interop",286    productId,287    type: "Auto-Renewable Subscription",288    environment: "Sandbox",289    purchaseDate: Date.now(),290    expiresDate: Date.now() + 3600000,291  };292  const appleJws = [293    Buffer.from('{"alg":"ES256"}').toString("base64url"),294    Buffer.from(JSON.stringify(applePayload)).toString("base64url"),295    "Zml4dHVyZQ",296  ].join(".");297  const appleLibrary = await import("@apple/app-store-server-library");298  mock.module("@apple/app-store-server-library", () => ({299    ...appleLibrary,300    AppStoreServerAPIClient: class {301      async getTransactionInfo(id) {302        assert.equal(id, applePayload.transactionId);303        return { signedTransactionInfo: appleJws };304      }305    },306    SignedDataVerifier: class {307      async verifyAndDecodeTransaction(jws) {308        assert.equal(jws, appleJws);309        return applePayload;310      }311    },312  }));313  const nativeFetch = globalThis.fetch;314  globalThis.fetch = async (url, init) => {315    const target = new URL(316      typeof url === "string" || url instanceof URL ? url : url.url,317    );318    if (target.hostname === "graph.oculus.com") {319      assert.equal(target.pathname, "/1234567890/verify_entitlement");320      const form = new URLSearchParams(init.body);321      assert.equal(form.get("user_id"), "store-user-horizon");322      assert.equal(form.get("sku"), productId);323      if (storeState.horizon === "outage")324        return Response.json({}, { status: 400 });325      return Response.json({326        success: storeState.horizon,327        grant_time: Math.floor(Date.now() / 1000),328      });329    }330    if (target.hostname === "appstore-sdk.amazon.com") {331      assert(target.pathname.includes("/sandbox/"));332      assert(333        target.pathname.endsWith(334          "/user/store-user-amazon/receiptId/local-amazon-receipt",335        ),336      );337      if (storeState.amazon === "outage")338        return Response.json({}, { status: 503 });339      return Response.json({340        receiptId: "local-amazon-receipt",341        productId,342        productType: "SUBSCRIPTION",343        purchaseDate: amazonPurchaseDate,344        cancelDate: storeState.amazon ? null : amazonPurchaseDate + 1000,345        testTransaction: true,346      });347    }348    return nativeFetch(url, init);349  };350  ctx.storage = {351    get: async (storageId) => {352      const text = await admin.action(anyApi.interopFixture.readStorage, {353        storageId,354      });355      return text === null ? null : new Blob([text]);356    },357  };358  const { verifyAppStoreReceiptInternalV1 } =359    await import("../../convex/purchases/ios.ts");360  const { verifyAmazonReceiptInternalV1 } =361    await import("../../convex/purchases/amazon.ts");362  const { verifyMetaHorizonReceiptInternalV1 } =363    await import("../../convex/purchases/horizon.ts");364  const { readBoundPurchaseEntitlements } =365    await import("../../convex/purchases/action.ts");366  const { verifyGooglePlayReceiptInternalV1 } =367    await import("../../convex/purchases/android.ts");368  const { ingestGoogleRtdn } = await import("../../convex/webhooks/google.ts");369  const { deliverPendingEventsHandler } =370    await import("../../convex/commerce/delivery.ts");371  const { client } = await import("../../server/convex.ts");372  const realAction = client.action.bind(client);373  const fixtureActions = {374    "purchases/android:verifyGooglePlayReceiptInternalV1":375      verifyGooglePlayReceiptInternalV1,376    "purchases/ios:verifyAppStoreReceiptInternalV1":377      verifyAppStoreReceiptInternalV1,378    "purchases/amazon:verifyAmazonReceiptInternalV1":379      verifyAmazonReceiptInternalV1,380    "purchases/horizon:verifyMetaHorizonReceiptInternalV1":381      verifyMetaHorizonReceiptInternalV1,382    "purchases/action:readBoundPurchaseEntitlements":383      readBoundPurchaseEntitlements,384  };385  client.action = (ref, args) =>386    fixtureActions[getFunctionName(ref)]387      ? fixtureActions[getFunctionName(ref)]._handler(ctx, args)388      : realAction(ref, args);389  const { commerceRoutes } =390    await import("../../server/api/commerce/routes.ts");391  kitServer = Bun.serve({392    hostname: "127.0.0.1",393    port: 0,394    fetch: new Hono().route("/commerce/v1", commerceRoutes).fetch,395  });396  const { createProvider, CREDENTIALS } = await import(397    join(example, "provider.mjs")398  );399  const { startConsumer } = await import(join(example, "consumer.mjs"));400  const { startAppBackend } = await import(401    join(example, "composition/app-backend.mjs")402  );403  const { createCommerceClient } = await import(404    join(example, "composition/commerce-client.mjs")405  );406  const { deliver } = await import(join(example, "webhooks.mjs"));407  const fixture = {408    store: "google",409    evidence: token,410    userId,411    productId,412    startsAt: startedAt - 60000,413    expiresAt,414  };415  const sqlitePath = join(output, "provider.sqlite");416  sqlite = createProvider(sqlitePath, Date.now, fixture);417  sqliteServer = Bun.serve({418    hostname: "127.0.0.1",419    port: 0,420    fetch: (request) => sqlite.fetch(request),421  });422  receiver = startConsumer({423    secret: [424      { name: "example", projectId: "commerce_example", secret: exampleSecret },425      { name: "iapkit", projectId: seeded.projectId, secret },426    ],427    path: join(output, "receiver.sqlite"),428  });429  const providers = {430    example: {431      baseUrl: `http://127.0.0.1:${sqliteServer.port}`,432      credential: CREDENTIALS.server,433    },434    iapkit: {435      baseUrl: `http://127.0.0.1:${kitServer.port}`,436      credential: `Bearer ${key}`,437    },438  };439  const sessions = new Map([440    ["Bearer local-alice", userId],441    ["Bearer local-bob", "interop_bob"],442  ]);443  appServer = startAppBackend({444    path: join(output, "app.sqlite"),445    providers,446    receiver,447    resolveSession: (request) =>448      sessions.get(request.headers.get("authorization")),449  });450  const appUrl = appServer.url,451    receiverUrl = receiver.url;452  const evidence = { store: "google", google: { purchaseToken: token } };453  const clients = Object.fromEntries(454    Object.entries(providers).map(([name, cfg]) => [455      name,456      createCommerceClient(cfg),457    ]),458  );459  let current = "example";460  async function appCall(461    path,462    method = "GET",463    body,464    session = "Bearer local-alice",465  ) {466    const response = await fetch(appServer.url + path, {467      method,468      headers: { authorization: session, "content-type": "application/json" },469      ...(body ? { body: JSON.stringify(body) } : {}),470    });471    const result = await response.json().catch(() => null);472    trace.push({473      provider: current,474      path,475      method,476      status: response.status,477      result,478    });479    return { status: response.status, result };480  }481  function select(name) {482    current = name;483    appServer.select(name);484    check(485      `${name}: app and receiver endpoints remain unchanged`,486      [appServer.url, receiver.url],487      [appUrl, receiverUrl],488    );489  }490  const rawEvents = [];491  async function receive(init) {492    rawEvents.push(init);493    return fetch(receiver.url, { ...init, method: "POST" });494  }495  async function drain(name, fail = false) {496    return name === "example"497      ? deliver(sqlite, exampleSecret, Date.now, (init) =>498          fail499            ? Promise.resolve(new Response(null, { status: 503 }))500            : receive(init),501        )502      : deliverPendingEventsHandler(ctx, async (req) =>503          fail504            ? 503505            : (await receive({ headers: req.headers, body: req.body })).status,506        );507  }508  async function observe(kind, id = kind) {509    googleState =510      kind === "expire"511        ? "SUBSCRIPTION_STATE_EXPIRED"512        : kind === "cancel"513          ? "SUBSCRIPTION_STATE_CANCELED"514          : "SUBSCRIPTION_STATE_ACTIVE";515    return ingestGoogleRtdn._handler(ctx, {516      apiKey: publishable,517      oidcToken: "local-fixture-oidc",518      rawMessage: "local-fixture",519      payload: {520        messageId: id,521        packageName: "dev.openiap.interop",522        eventTimeMillis: Date.now(),523        subscriptionNotification: {524          notificationType:525            kind === "expire"526              ? 13527              : kind === "cancel"528                ? 3529                : kind === "renew"530                  ? 2531                  : 4,532          purchaseToken: token,533          subscriptionId: productId,534        },535      },536    });537  }538  for (const name of Object.keys(providers)) {539    select(name);540    results[name].before = (await appCall("/access")).result;541    check(542      `${name}: empty provider gives no access`,543      results[name].before.productIds,544      [],545    );546    check(547      `${name}: actual verification accepts fixture`,548      (await clients[name].call("verifyPurchase", evidence)).isValid,549      true,550    );551    check(552      `${name}: verification alone does not grant access`,553      (await appCall("/access")).result.productIds,554      [],555    );556    results[name].bound = (557      await appCall("/purchase", "POST", {558        ...evidence,559        userId: "attacker_supplied",560      })561    ).result;562    check(563      `${name}: unchanged app fulfills the purchase`,564      results[name].bound.productIds,565      [productId],566    );567    check(568      `${name}: repeated fulfillment is idempotent`,569      (await appCall("/purchase", "POST", evidence)).result.productIds,570      [productId],571    );572    check(573      `${name}: body cannot select account identity`,574      (575        await clients[name].call("entitlements", {576          userId: "attacker_supplied",577        })578      ).productIds,579      [],580    );581    check(582      `${name}: another user cannot claim the purchase`,583      (584        await clients[name].call("bindPurchase", {585          ...evidence,586          userId: "interop_bob",587        })588      ).bound,589      false,590    );591    const credential =592      name === "example" ? CREDENTIALS.verification : `Bearer ${publishable}`;593    const r = await fetch(594      providers[name].baseUrl + "/commerce/v1/users/erase",595      {596        method: "POST",597        headers: {598          authorization: credential,599          "content-type": "application/json",600        },601        body: JSON.stringify({ userId }),602      },603    );604    check(605      `${name}: publishable verification credentials cannot erase`,606      r.status,607      403,608    );609    if (name === "iapkit") await observe("start");610    await drain(name);611    check(612      `${name}: same receiver persists an authenticated grant`,613      receiver614        .inspect()615        .some(616          (event) =>617            event.projectId ===618              (name === "example" ? "commerce_example" : seeded.projectId) &&619            event.eventType === "entitlement.granted" &&620            event.userId === userId,621        ),622      true,623    );624    if (name === "example")625      sqlite.observe({ id: "cancel", kind: "cancel", occurredAt: Date.now() });626    else await observe("cancel");627    const state = await clients[name].call("subscriptionStatus", { userId });628    results[name].canceled = state;629    check(630      `${name}: cancellation stops renewal but keeps paid access`,631      [state.active, state.subscription.willRenew],632      [true, false],633    );634    await drain(name, true);635  }636  select("example");637  check(638    "Switching back restores the same persisted access",639    (await appCall("/access")).result.productIds,640    [productId],641  );642  const pendingBefore = await admin.query(643    anyApi.interopFixture.inspect,644    seeded,645  );646  check(647    "IAPKit 503 leaves a durable pending delivery",648    pendingBefore.deliveries.some(649      (row) => row.status === "pending" && row.attempts === 1,650    ),651    true,652  );653  console.log(654    "Restarting the actual local Convex process and SQLite provider with retries pending.",655  );656  await stop();657  await boot();658  sqlite.close();659  sqlite = createProvider(sqlitePath, Date.now, fixture);660  check(661    "IAPKit pending deliveries survive process restart",662    (await admin.query(anyApi.interopFixture.inspect, seeded)).deliveries,663    pendingBefore.deliveries,664  );665  const retryAt = Math.max(666    ...pendingBefore.deliveries667      .filter((row) => row.status === "pending")668      .map((row) => row.nextAttemptAt),669  );670  await Bun.sleep(Math.max(0, retryAt + 10 - Date.now()));671  for (const name of Object.keys(providers)) await drain(name);672  for (const projectId of ["commerce_example", seeded.projectId])673    check(674      `${projectId === "commerce_example" ? "example" : "iapkit"}: cancellation retry reaches unchanged receiver`,675      receiver676        .inspect()677        .some(678          (event) =>679            event.projectId === projectId &&680            event.eventType === "subscription.canceled",681        ),682      true,683    );684  const count = receiver.count();685  receiver.reopen();686  for (const init of rawEvents.slice()) await receive(init);687  check(688    "Lost acknowledgements and receiver restart create no duplicate effects",689    receiver.count(),690    count,691  );692  console.log("Waiting for the actual paid expiry deadline.");693  await Bun.sleep(Math.max(0, expiresAt + 10 - Date.now()));694  for (const name of Object.keys(providers)) {695    select(name);696    results[name].expired = (await appCall("/access")).result;697    check(698      `${name}: access expires before a store notification`,699      results[name].expired.productIds,700      [],701    );702  }703  sqlite.observe({ id: "expire", kind: "expire", occurredAt: Date.now() });704  await observe("expire");705  check(706    "Repeated IAPKit store notification is deduplicated",707    (await observe("expire")).deduped,708    true,709  );710  for (const name of Object.keys(providers)) await drain(name);711  for (const projectId of ["commerce_example", seeded.projectId])712    check(713      `${projectId === "commerce_example" ? "example" : "iapkit"}: receiver observes revocation`,714      receiver715        .inspect()716        .some(717          (event) =>718            event.projectId === projectId &&719            event.eventType === "entitlement.revoked",720        ),721      true,722    );723  check(724    "App account deletion is accepted",725    (await appCall("/account", "DELETE")).result.accepted,726    true,727  );728  for (let i = 0; i < 80 && (await appServer.drainErasure()); i++)729    await Bun.sleep(100);730  check(731    "App completes erasure across both configured providers",732    await appServer.drainErasure(),733    0,734  );735  check(736    "Deleted session cannot submit a late purchase",737    (await appCall("/purchase", "POST", evidence)).status,738    401,739  );740  for (const name of Object.keys(providers)) {741    results[name].erased = await clients[name].call("entitlements", { userId });742    check(743      `${name}: erased user has no access`,744      results[name].erased.productIds,745      [],746    );747    const first = await clients[name].call("eraseUser", { userId }),748      again = await clients[name].call("eraseUser", { userId });749    results[name].erasure = again;750    check(751      `${name}: repeated erasure returns the same completed job`,752      [first.status, again.jobId],753      ["completed", first.jobId],754    );755  }756  const erasedState = await admin.query(anyApi.interopFixture.inspect, seeded);757  check(758    "IAPKit persisted subscription, event and job identities are removed",759    JSON.stringify(erasedState).includes(userId),760    false,761  );762  check(763    "Example purchase identity is removed",764    sqlite.inspect().purchases[0].userId,765    null,766  );767  check(768    "Previously delivered app event identities are removed",769    receiver.inspect().some((event) => event.userId === userId),770    false,771  );772  receiver.reopen();773  for (const init of rawEvents.slice()) await receive(init);774  check(775    "Late signed redeliveries after erasure and restart cannot restore identities",776    receiver.inspect().some((event) => event.userId === userId),777    false,778  );779  check(780    "Consumer source files are byte-for-byte unchanged during replacement",781    Object.fromEntries(782      unchangedFiles.map((name) => [name, hash(join(example, name))]),783    ),784    before,785  );786  const { runStoreCoverage } = await import("./commerce-store-coverage.mjs");787  const storeCoverage = await runStoreCoverage({788    example,789    output,790    key,791    kitUrl: `http://127.0.0.1:${kitServer.port}`,792    receiver,793    appleJws,794    storeState,795    inspect: () =>796      admin.query(anyApi.interopFixture.inspect, {797        projectId: seeded.projectId,798      }),799    check,800    trace,801  });802  let freshConnection;803  if (freshExample) {804    const drained = await drain("iapkit");805    check(806      "Earlier store fixture events reach their original receiver",807      drained.delivered,808      drained.attempted,809    );810    check(811      "Fresh paywall starts without earlier pending deliveries",812      (813        await admin.query(anyApi.interopFixture.inspect, {814          projectId: seeded.projectId,815        })816      ).deliveries.filter((row) =>817        ["pending", "delivering"].includes(row.status),818      ).length,819      0,820    );821    token = "fresh-paywall-google-token";822    orderId = "GPA.fresh-fixture-0";823    expiresAt = Date.now() + 60000;824    googleState = "SUBSCRIPTION_STATE_ACTIVE";825    // The independent paywall assigns its fictional account in production.826    googleTestPurchase = false;827    const { verifyProvider } = await import(828      join(freshExample, "verify-provider.mjs")829    );830    freshConnection = await verifyProvider({831      provider: { baseUrl: kitServer.url.origin, credential: key },832      projectId: seeded.projectId,833      secret,834      sample: {835        id: "google",836        label: "IAPKit Google fixture",837        userId: "fresh_paywall_user",838        productId,839        variant: "A",840        receipt: { store: "google", google: { purchaseToken: token } },841      },842      observe: async (kind) => {843        if (kind === "renew") {844          expiresAt += 60000;845          orderId = "GPA.fresh-fixture-1";846        }847        return observe(kind, "fresh-paywall-" + kind);848      },849      deliver: (receive) => deliverPendingEventsHandler(ctx, receive),850    });851    freshInputs.assertUnchanged();852    freshConnection.source = {853      revision: revision(freshExample),854      hashes: freshInputs.hashes,855    };856    console.log(857      `Completed ${freshConnection.checks.length} fresh paywall checks.`,858    );859  }860  for (const snapshot of Object.values(inputs)) snapshot.assertUnchanged();861  check(862    "Source revisions remain unchanged throughout execution",863    { openiap: revision(repo), example: revision(example) },864    revisions,865  );866  // Provenance the published pages cite. Derived from the revisions recorded867  // above, never authored by hand: a hand-written commit cannot see the868  // working tree, and an evidence page that names a bare commit for a dirty869  // run is a false claim about what executed.870  const reproduction = {871    openiapBaseCommit: revisions.openiap,872    originalExampleCommit: revisions.example,873    ...(freshConnection874      ? { freshExampleCommit: freshConnection.source.revision }875      : {}),876    instructions:877      "https://openiap.dev/commerce-example/paywall-provider-reproduction.md",878    dependencyScope:879      "Both standalone examples were installed from public GitHub checkouts. " +880      "IAPKit used its existing dependency installation; executed source hashes " +881      "and runtime versions are in this report.",882  };883  for (const [name, value] of Object.entries(reproduction)) {884    assert(885      !String(value).endsWith("-dirty"),886      `Refusing to publish evidence: ${name} ran against uncommitted changes ` +887        `(${value}). Commit or use a clean checkout, then re-record.`,888    );889  }890 891  const report = {892    ...(freshConnection ? { freshConnection } : {}),893    reproduction,894    results,895    storeCoverage,896    harnessHashes: inputs.harness.hashes,897    sources: {898      openiap: {899        baseRevision: revisions.openiap,900        hashes: inputs.openiap.hashes,901        optionalGeneratedFiles: optionalInputs.openiap,902      },903      example: {904        baseRevision: revisions.example,905        hashes: inputs.example.hashes,906      },907      workspace: {908        baseRevision: revisions.openiap,909        hashes: inputs.workspace.hashes,910        optionalGeneratedFiles: optionalInputs.workspace,911      },912    },913    runtime: {914      bun: Bun.version,915      convex: convexVersion,916    },917    // Reconstructed from what actually ran, not a fixed string: the fresh918    // example is a third argument and the report must not omit it.919    command: [920      "bun --conditions=openiap-source",921      "packages/kit/scripts/docs/run-commerce-interop.mjs",922      "<original-example-checkout>",923      "<new-output-directory>",924      ...(freshExample ? ["<fresh-example-checkout>"] : []),925    ].join(" "),926    configurationChanges: [927      "Commerce base URL and server credential",928      "Separate webhook signing keys, each bound to its provider and project",929      "Example fixture store adapter configured to recognize the same synthetic Google token",930    ],931    recordedAt: new Date().toISOString(),932    checks,933    checkCount: checks.length,934    trace,935    consumerHashes: before,936    changedConsumerFiles: [],937    providerOrder: ["example", "iapkit", "example"],938    backendVersion: config.backendVersion,939    scope: {940      actual: [941        "IAPKit Hono routes and handlers",942        "Apple, Google, Amazon and Horizon verification action bodies; Google RTDN action",943        "Amazon and Horizon stored purchase binding, fresh ownership reads and erasure",944        "Convex persistence, authorization, subscription transitions, erasure scheduler and delivery leases",945        "IAPKit outbound signing and result recording",946        "Original example SQLite provider",947        "One app backend and one receiver, real loopback HTTP",948        "Actual Convex process restart with pending deliveries",949      ],950      fixtures: [951        "Google Play HTTP and OIDC, Amazon RVS and Meta Graph responses",952        "Apple signed-transaction verifier, Server API and test signing-key loader at their external boundaries",953        "Paid subscription and app sessions",954        "HTTPS destination transport redirected to the loopback receiver through the existing worker injection",955      ],956      excluded: [957        "Real store purchase, device SDK integration and store cryptographic verification",958        "Public HTTPS DNS/TLS delivery",959        "Automatic migration of existing purchases between providers",960        "Unrelated image processing and scheduled maintenance crons",961      ],962    },963  };964  writeFileSync(965    join(output, "report.json"),966    JSON.stringify(report, null, 2) + "\n",967  );968  console.log(969    `Completed ${checks.length} checks. Report: ${join(output, "report.json")}`,970  );971} catch (error) {972  writeFileSync(973    join(output, "failure.json"),974    JSON.stringify(975      {976        recordedAt: new Date().toISOString(),977        completedChecks: checks,978        assertion: error.message,979      },980      null,981      2,982    ) + "\n",983  );984  throw error;985} finally {986  await appServer?.close();987  await receiver?.close();988  await sqliteServer?.stop(true);989  sqlite?.close();990  await kitServer?.stop(true);991  await stop();992}993