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