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