← Provider replacement walkthrough

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

Source snapshot · 2026-09-09

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