1// IAPKit's dual-binding conformance proof: the spec package's portable runner2// drives the real commerce routes — REST and GraphQL, through the real3// adapters, shared handlers, auth, and validation — against an in-memory4// Convex substitute seeded from the runner's own fixtures. Amazon and Horizon5// purchases run through IAPKit's own purchase functions, answered by fixture6// store responses; no request reaches a store. SPEC.md 11.3 scopes what this7// certifies.8 9import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";10import { Hono } from "hono";11import Ajv from "ajv/dist/2020";12import { getFunctionName } from "convex/server";13import {14 createGraphqlAdapter,15 createRestAdapter,16 operationVectors,17 runConformance,18} from "@hyodotdev/openiap-commerce-protocol/conformance";19 20import {21 SIGNATURE_TOLERANCE_SECONDS,22 classifyDeliveryResponse,23 composeDeliveryHeaders,24 signPayload,25} from "../../../convex/commerce/signing";26import {27 commerceEventTypesToEmit,28 type CommerceEventType,29} from "../../../convex/commerce/contract";30import { entitlementActive } from "../../../convex/subscriptions/stateMachine";31import { getProjectByApiKey as getProjectByApiKeyQuery } from "../../../convex/projects/internal";32import { readBoundPurchaseEntitlements as readBoundPurchaseEntitlementsAction } from "../../../convex/purchases/action";33import { verifyAmazonReceiptInternalV1 } from "../../../convex/purchases/amazon";34import { verifyMetaHorizonReceiptInternalV1 } from "../../../convex/purchases/horizon";35import {36 buildAmazonRemoteId,37 buildHorizonRemoteId,38} from "../../../convex/purchases/identity";39import { boundPurchasesForUser as boundPurchasesForUserQuery } from "../../../convex/purchases/internal";40import { bindVerifiedPurchaseAsServer as bindVerifiedPurchaseAsServerMutation } from "../../../convex/purchases/mutation";41import { HarmonizedPurchaseState } from "../../../convex/purchases/purchaseState";42import { isValidState } from "../../../convex/purchases/shared";43import {44 reserveAmazonRvsSequenceHandler,45 type saveAmazonVerdictHandler,46} from "../../../convex/purchases/internal";47import { consume as consumeAdmissionMutation } from "../../../convex/purchases/verificationAdmission";48import { testableFunction } from "../../../convex/test.setup";49 50// IAPKit's descriptor declares the events profile, so dual-binding conformance51// drives the EventsAdapter surface (SPEC.md §11.2/§11.3 scope its coverage;52// §9.2/§9.3/§9.4.4/§9.4.5 are certified by IAPKit's own convex tests, not53// here). Every method but one delegates to SHIPPED code: signing (signPayload),54// the delivery envelope (composeDeliveryHeaders, which the worker in55// convex/commerce/delivery.ts sends), response classification56// (classifyDeliveryResponse, which the worker records), the emission rules57// (commerceEventTypesToEmit), and the entitlement gate (entitlementActive). Only58// `verify` has no production counterpart — IAPKit emits webhooks, consumers59// verify them — so it is written here from the shipped tolerance.60const iapkitEventsAdapter = {61 sign: ({62 secret,63 timestamp,64 body,65 }: {66 secret: string;67 timestamp: number;68 body: string;69 }) => signPayload(secret, timestamp, body),70 verify: async ({71 body,72 timestamp,73 signature,74 secrets,75 now,76 }: {77 body: string;78 timestamp: number;79 signature: string;80 secrets: string[];81 now: number;82 }) => {83 if (Math.abs(now - timestamp) > SIGNATURE_TOLERANCE_SECONDS) return false;84 const held = await Promise.all(85 secrets.map((secret) => signPayload(secret, timestamp, body)),86 );87 return signature88 .split(",")89 .map((part) => part.trim())90 .some((presented) => held.includes(presented));91 },92 delivery: async ({93 event,94 body,95 timestamp,96 secrets,97 deliveryId,98 }: {99 event: { eventId: string };100 body: string;101 timestamp: number;102 secrets: string[];103 deliveryId: string;104 }) => {105 const headers = await composeDeliveryHeaders({106 secrets: { current: secrets[0], previous: secrets[1] },107 timestampSeconds: timestamp,108 body,109 eventId: event.eventId,110 deliveryId,111 });112 // POST is how delivery.ts sends (buildPinnedRequestOptions); there is no113 // method constant to import.114 return { method: "POST", contentType: headers["content-type"], headers };115 },116 // The worker passes `undefined` when no status arrived (its catch path).117 classifyResponse: (status: number | "connection-error" | "timeout") =>118 classifyDeliveryResponse(typeof status === "number" ? status : undefined),119 // Store-agnostic: the kit-only Apple guard is pinned against the same120 // vectors in convex/commerce/spec.conformance.test.ts.121 entitled: ({122 state,123 expiresAt,124 processedAt,125 }: {126 state: string;127 expiresAt?: number;128 processedAt: number;129 }) => entitlementActive({ state, expiresAt }, processedAt),130 emission: ({131 lifecycleEvent,132 entitledBefore,133 entitledAfter,134 }: {135 lifecycleEvent: string | null;136 entitledBefore: boolean;137 entitledAfter: boolean;138 }) =>139 commerceEventTypesToEmit({140 lifecycleType: (lifecycleEvent ?? null) as CommerceEventType | null,141 active: entitledAfter,142 previouslyActive: entitledBefore,143 hasBoundUser: true,144 }),145 coalesceAtBinding: ({ entitledAtBinding }: { entitledAtBinding: boolean }) =>146 commerceEventTypesToEmit({147 lifecycleType: null,148 active: entitledAtBinding,149 previouslyActive: false,150 hasBoundUser: true,151 }),152};153 154const FIXTURES = operationVectors.fixtures as {155 userId: string;156 erasureUserId: string;157 appleJws: string;158 googlePurchaseToken: string;159 amazonUserId: string;160 amazonReceiptId: string;161 horizonUserId: string;162 horizonSku: string;163};164 165const mocks = vi.hoisted(() => ({166 action: vi.fn(),167 mutation: vi.fn(),168 query: vi.fn(),169 handleConvexError: vi.fn(),170}));171 172vi.mock("@/convex", () => ({173 api: {174 purchases: {175 action: {176 readBoundPurchaseEntitlements: "readBoundPurchaseEntitlements",177 },178 mutation: {179 bindVerifiedPurchaseAsServer: "bindVerifiedPurchaseAsServer",180 },181 ios: { verifyAppStoreReceiptInternalV1: "verifyApple" },182 android: { verifyGooglePlayReceiptInternalV1: "verifyGoogle" },183 horizon: { verifyMetaHorizonReceiptInternalV1: "verifyHorizon" },184 amazon: { verifyAmazonReceiptInternalV1: "verifyAmazon" },185 },186 subscriptions: {187 query: {188 subscriptionStatusV2: "subscriptionStatusV2",189 entitlementsV2: "entitlementsV2",190 assertServerAccess: "assertServerAccess",191 },192 mutation: {193 bindUserAsServer: "bindUserAsServer",194 requestUserErasure: "requestUserErasure",195 },196 },197 },198}));199 200vi.mock("../../convex", () => ({201 client: {202 action: mocks.action,203 mutation: mocks.mutation,204 query: mocks.query,205 },206 handleConvexError: mocks.handleConvexError,207}));208 209const { commerceRoutes } = await import("./routes");210 211const CREDENTIALS = {212 verification: "openiap-kit_pk_conformance",213 server: "openiap-kit_sk_conformance",214};215const BASE_URL = "https://kit.conformance.example";216 217function buildApp(): Hono {218 const app = new Hono();219 app.route("/commerce/v1", commerceRoutes);220 return app;221}222 223// A ConvexError the real key check would throw, so an unknown key becomes224// UNAUTHORIZED and a publishable key on a server op becomes FORBIDDEN — the225// authoritative classification the edge prefix check cannot make.226class FakeConvexError extends Error {227 constructor(readonly data: { code: string; message: string }) {228 super(data.message);229 }230}231 232function assertKnownKey(apiKey: unknown): void {233 if (apiKey !== CREDENTIALS.verification && apiKey !== CREDENTIALS.server) {234 throw new FakeConvexError({235 code: "INVALID_API_KEY",236 message: "API key is invalid or inactive",237 });238 }239}240 241function assertServerKey(apiKey: unknown): void {242 assertKnownKey(apiKey);243 if (apiKey !== CREDENTIALS.server) {244 throw new FakeConvexError({245 code: "INSUFFICIENT_SCOPE",246 message: "This operation requires a secret admin key",247 });248 }249}250 251const getProjectByApiKey = testableFunction(getProjectByApiKeyQuery)._handler;252const boundPurchasesForUser = testableFunction(253 boundPurchasesForUserQuery,254)._handler;255const consumeAdmission = testableFunction(consumeAdmissionMutation)._handler;256const readBoundPurchaseEntitlements = testableFunction(257 readBoundPurchaseEntitlementsAction,258)._handler;259const bindVerifiedPurchaseAsServer = testableFunction(260 bindVerifiedPurchaseAsServerMutation,261)._handler;262const verifyAmazonReceipt = testableFunction(263 verifyAmazonReceiptInternalV1,264)._handler;265const verifyHorizonReceipt = testableFunction(266 verifyMetaHorizonReceiptInternalV1,267)._handler;268 269type Row = Record<string, unknown> & { _id: string };270type ConvexReference = Parameters<typeof getFunctionName>[0];271type EqualityRange = { eq(field: string, value: unknown): EqualityRange };272type FieldFilter = {273 field(name: string): string;274 eq(field: string, value: unknown): (row: Row) => boolean;275};276 277const PROJECT_ID = "projects:conformance";278const ORGANIZATION_ID = "organizations:conformance";279const AMAZON_PRODUCT = "conformance.amazon.premium";280 281// The slice of ctx.db the purchase functions use: equality index lookups, a282// field filter, get, insert, and patch.283function memoryDatabase(tables: Record<string, Row[]>) {284 const byId = (id: unknown): Row | null =>285 Object.values(tables)286 .flat()287 .find((row) => row._id === id) ?? null;288 return {289 query(table: string) {290 const matches: Array<(row: Row) => boolean> = [];291 const rows = () =>292 (tables[table] ?? []).filter((row) =>293 matches.every((match) => match(row)),294 );295 const query = {296 withIndex(_index: string, range: (q: EqualityRange) => unknown) {297 const equality: EqualityRange = {298 eq(field, value) {299 matches.push((row) => row[field] === value);300 return equality;301 },302 };303 range(equality);304 return query;305 },306 filter(predicate: (q: FieldFilter) => (row: Row) => boolean) {307 matches.push(308 predicate({309 field: (name) => name,310 eq: (field, value) => (row) => row[field] === value,311 }),312 );313 return query;314 },315 first: async () => rows()[0] ?? null,316 unique: async () => {317 const found = rows();318 if (found.length > 1) throw new Error(`Ambiguous ${table} rows`);319 return found[0] ?? null;320 },321 take: async (count: number) => rows().slice(0, count),322 };323 return query;324 },325 get: async (id: unknown) => byId(id),326 insert: async (table: string, value: Record<string, unknown>) => {327 const rows = (tables[table] ??= []);328 const _id = `${table}:${rows.length}`;329 rows.push({ _id, ...value });330 return _id;331 },332 patch: async (id: unknown, value: Record<string, unknown>) => {333 const row = byId(id);334 if (!row) throw new Error(`Missing row ${String(id)}`);335 Object.assign(row, value);336 },337 };338}339 340// One project whose Amazon and Horizon purchases are already bound to the341// fixture user, so both bindings read the same state in any case order.342function purchaseTables(): Record<string, Row[]> {343 const bound = (344 store: string,345 remoteId: string,346 productId: string,347 requestData: Record<string, unknown>,348 ): Row => ({349 _id: `purchases:${store}`,350 projectId: PROJECT_ID,351 store,352 remoteId,353 requestData: { store, ...requestData },354 productId,355 state: HarmonizedPurchaseState.ENTITLED,356 isValid: true,357 appUserId: FIXTURES.userId,358 });359 const amazon = {360 userId: FIXTURES.amazonUserId,361 receiptId: FIXTURES.amazonReceiptId,362 sandbox: true,363 };364 return {365 organizations: [{ _id: ORGANIZATION_ID }],366 projects: [367 {368 _id: PROJECT_ID,369 organizationId: ORGANIZATION_ID,370 amazonSandboxEnabled: true,371 horizonEnabled: true,372 horizonAppId: "conformance-horizon-app",373 horizonAppSecret: "conformance-horizon-secret",374 },375 ],376 apiKeys: [377 {378 _id: "apiKeys:server",379 projectId: PROJECT_ID,380 organizationId: ORGANIZATION_ID,381 key: CREDENTIALS.server,382 keyType: "secret",383 },384 {385 _id: "apiKeys:verification",386 projectId: PROJECT_ID,387 organizationId: ORGANIZATION_ID,388 key: CREDENTIALS.verification,389 keyType: "publishable",390 },391 ],392 purchases: [393 bound("amazon", buildAmazonRemoteId(amazon), AMAZON_PRODUCT, amazon),394 bound(395 "horizon",396 buildHorizonRemoteId(FIXTURES.horizonUserId, FIXTURES.horizonSku),397 FIXTURES.horizonSku,398 { userId: FIXTURES.horizonUserId, sku: FIXTURES.horizonSku },399 ),400 ],401 subscriptionUserErasureJobs: [],402 };403}404 405// Runs IAPKit's purchase functions over the tables. Persisting a store verdict406// only updates the row; everything else is the real handler. Arguments are407// typed `never` because each call forwards what the calling Convex code sent.408function purchaseFunctions() {409 const tables = purchaseTables();410 const ctx = {411 db: memoryDatabase(tables),412 runQuery: (reference: ConvexReference, args: never) =>413 runInternal(reference, args),414 runMutation: (reference: ConvexReference, args: never) =>415 runInternal(reference, args),416 };417 function recordVerdict(verdict: {418 store: string;419 remoteId?: string;420 state: string;421 isValid: boolean;422 }): null {423 const row = tables.purchases.find(424 (purchase) =>425 purchase.store === verdict.store &&426 purchase.remoteId === verdict.remoteId,427 );428 if (row)429 Object.assign(row, { state: verdict.state, isValid: verdict.isValid });430 return null;431 }432 async function runInternal(433 reference: ConvexReference,434 args: never,435 ): Promise<unknown> {436 switch (getFunctionName(reference)) {437 case "projects/internal:getProjectByApiKey":438 return getProjectByApiKey(ctx, args);439 case "purchases/internal:boundPurchasesForUser":440 return boundPurchasesForUser(ctx, args);441 case "purchases/verificationAdmission:consume":442 return consumeAdmission(ctx, args);443 case "purchases/internal:saveReceiptInternal":444 return recordVerdict(args);445 case "purchases/internal:reserveAmazonRvsSequence":446 return reserveAmazonRvsSequenceHandler(ctx as never, args);447 case "purchases/internal:saveAmazonVerdict": {448 const verdict = args as Parameters<typeof saveAmazonVerdictHandler>[1];449 const isValid = isValidState(verdict.state);450 recordVerdict({451 store: "amazon",452 remoteId: verdict.remoteId,453 state: verdict.state,454 isValid,455 });456 const receipt = JSON.parse(verdict.remoteResponse) as {457 productId?: string;458 };459 return {460 isValid,461 state: verdict.state,462 environment: verdict.environment,463 ...(receipt.productId ? { productId: receipt.productId } : {}),464 };465 }466 default:467 throw new Error(468 `Unexpected Convex function ${getFunctionName(reference)}`,469 );470 }471 }472 return ctx;473}474 475// Fixture store answers for the fixture evidence. Any other host throws, so476// the run never reaches a real store.477const storeRequests: string[] = [];478async function storeFetch(input: string | URL | Request): Promise<Response> {479 const { hostname } = new URL(input instanceof Request ? input.url : input);480 storeRequests.push(hostname);481 if (hostname === "appstore-sdk.amazon.com") {482 return Response.json({483 receiptId: FIXTURES.amazonReceiptId,484 productId: AMAZON_PRODUCT,485 productType: "ENTITLED",486 cancelDate: null,487 });488 }489 if (hostname === "graph.oculus.com") {490 return Response.json({ success: true, grant_time: 1_758_000_000 });491 }492 throw new Error(`Unexpected store request to ${hostname}`);493}494 495// Apple and Google verdicts need signed store payloads, so they stay canned.496function cannedVerdict(name: unknown, args: { apiKey: string }) {497 assertKnownKey(args.apiKey);498 return {499 isValid: true,500 state: "ENTITLED",501 productId: "premium.monthly",502 environment: name === "verifyGoogle" ? "Production" : "Sandbox",503 };504}505 506function seedConvexFixtures() {507 const now = Date.now();508 // Shaped like the real subscriptionV2Shape, id included, so the test proves509 // the handler strips the provider-internal id from tokenless responses.510 const row = {511 id: "subscriptions:mock-row-1",512 productId: "premium.monthly",513 platform: "Android" as const,514 state: "Active",515 expiresAt: now + 30 * 86_400_000,516 willRenew: true,517 startedAt: now - 86_400_000,518 updatedAt: now - 1_000,519 };520 const purchases = purchaseFunctions();521 522 mocks.handleConvexError.mockImplementation((error: unknown) =>523 error instanceof FakeConvexError ? error.data : null,524 );525 526 mocks.action.mockImplementation(async (name: unknown, args: never) => {527 switch (name) {528 case "readBoundPurchaseEntitlements":529 return readBoundPurchaseEntitlements(purchases, args);530 case "verifyAmazon":531 return verifyAmazonReceipt(purchases, args);532 case "verifyHorizon":533 return verifyHorizonReceipt(purchases, args);534 default:535 return cannedVerdict(name, args);536 }537 });538 539 mocks.query.mockImplementation(async (name: unknown, args: unknown) => {540 const { apiKey, userId } = args as { apiKey: string; userId: string };541 assertServerKey(apiKey);542 if (name === "assertServerAccess") {543 return { ok: true };544 }545 const owned = userId === FIXTURES.userId;546 if (name === "subscriptionStatusV2") {547 return { active: owned, subscription: owned ? row : null };548 }549 if (name === "entitlementsV2") {550 return {551 userId,552 productIds: owned ? [row.productId] : [],553 subscriptions: owned ? [row] : [],554 };555 }556 throw new Error(`unexpected query ${String(name)}`);557 });558 559 const erasureJobs = new Map<string, { jobId: string; status: string }>();560 const subscriptionMutation = (name: unknown, args: unknown) => {561 const { apiKey } = args as { apiKey: string };562 assertServerKey(apiKey);563 if (name === "bindUserAsServer") {564 const { purchaseToken, userId } = args as {565 purchaseToken: string;566 userId: string;567 };568 return {569 ok: true,570 bound:571 purchaseToken === FIXTURES.googlePurchaseToken &&572 userId === FIXTURES.userId,573 };574 }575 if (name === "requestUserErasure") {576 const { userId } = args as { userId: string };577 const job = erasureJobs.get(userId) ?? {578 jobId: `job-${erasureJobs.size + 1}`,579 status: "queued",580 };581 erasureJobs.set(userId, job);582 return { ok: true, ...job };583 }584 throw new Error(`unexpected mutation ${String(name)}`);585 };586 mocks.mutation.mockImplementation(async (name: unknown, args: never) =>587 name === "bindVerifiedPurchaseAsServer"588 ? bindVerifiedPurchaseAsServer(purchases, args)589 : subscriptionMutation(name, args),590 );591}592 593describe("IAPKit dual-binding conformance", () => {594 beforeEach(() => {595 mocks.action.mockReset();596 mocks.mutation.mockReset();597 mocks.query.mockReset();598 mocks.handleConvexError.mockReset();599 mocks.handleConvexError.mockReturnValue(null);600 seedConvexFixtures();601 storeRequests.length = 0;602 vi.stubGlobal("fetch", storeFetch);603 });604 605 afterEach(() => {606 vi.unstubAllGlobals();607 });608 609 it("passes every operation vector on both bindings with cross-binding parity", async () => {610 const app = buildApp();611 const fetchApp = async (url: string, options?: RequestInit) =>612 app.request(url, options);613 614 const report = await runConformance({615 adapters: [616 createRestAdapter({617 baseUrl: BASE_URL,618 fetch: fetchApp,619 credentials: CREDENTIALS,620 }),621 createGraphqlAdapter({622 url: `${BASE_URL}/commerce/v1/graphql`,623 fetch: fetchApp,624 credentials: CREDENTIALS,625 }),626 ],627 Ajv,628 eventsAdapter: iapkitEventsAdapter,629 credentials: CREDENTIALS,630 });631 632 expect(report.results.filter((result) => !result.ok)).toEqual([]);633 // The declared events profile was actually verified, not silently skipped.634 expect(635 report.results.some((r) => r.id === "events.profile-verification"),636 ).toBe(true);637 expect(report.parityFailures).toEqual([]);638 expect(report.ok).toBe(true);639 const bindings = new Set(report.results.map((result) => result.binding));640 expect([...bindings].sort()).toEqual(["graphql", "rest"]);641 // The Amazon and Horizon paths reached the fixture store answers.642 expect(new Set(storeRequests)).toEqual(643 new Set(["appstore-sdk.amazon.com", "graph.oculus.com"]),644 );645 });646 647 it("lists store-confirmed Amazon and Horizon products without subscription records", async () => {648 const response = await buildApp().request(649 `/commerce/v1/entitlements?userId=${FIXTURES.userId}`,650 { headers: { Authorization: `Bearer ${CREDENTIALS.server}` } },651 );652 expect(response.status).toBe(200);653 const body = await response.json();654 expect(body.productIds.sort()).toEqual(655 ["premium.monthly", AMAZON_PRODUCT, FIXTURES.horizonSku].sort(),656 );657 expect(658 body.subscriptions.map(659 (record: { productId: string }) => record.productId,660 ),661 ).toEqual(["premium.monthly"]);662 });663 664 // The runner also accepts VERIFICATION_FAILED for these vectors, so pin the665 // verdict itself.666 it.each(["amazon", "horizon"])(667 "verifies the %s fixture purchase against the fixture store answer",668 async (store) => {669 const vector = operationVectors.cases.find(670 (candidate) =>671 candidate.id === `verifyPurchase.success.contract.${store}`,672 );673 const response = await buildApp().request(674 "/commerce/v1/purchases/verify",675 {676 method: "POST",677 headers: {678 Authorization: `Bearer ${CREDENTIALS.verification}`,679 "Content-Type": "application/json",680 },681 body: JSON.stringify(vector?.input),682 },683 );684 expect(response.status).toBe(200);685 expect((await response.json()).isValid).toBe(true);686 },687 );688 689 it("fails the entitlements read with VERIFICATION_FAILED when the store cannot answer", async () => {690 // RVS 496 is a store-side fault, not a verdict about the receipt.691 vi.stubGlobal("fetch", async () => new Response("", { status: 496 }));692 const response = await buildApp().request(693 `/commerce/v1/entitlements?userId=${FIXTURES.userId}`,694 { headers: { Authorization: `Bearer ${CREDENTIALS.server}` } },695 );696 expect(response.status).toBe(502);697 expect((await response.json()).error.code).toBe("VERIFICATION_FAILED");698 });699 700 it("keeps the served capability descriptor equal to the published one, minus the comment", async () => {701 const app = buildApp();702 const response = await app.request("/commerce/v1/capabilities");703 expect(response.status).toBe(200);704 const served = await response.json();705 const { $comment, ...published } = (706 await import("@hyodotdev/openiap-commerce-protocol/examples/provider-capabilities.json")707 ).default as Record<string, unknown>;708 expect($comment).toBeTruthy();709 expect(served).toEqual(published);710 });711});712 713// Synthetic service credential for this isolated verification transport fixture.714beforeEach(() =>715 vi.stubEnv("IAPKIT_VERIFICATION_SOURCE_SECRET", "1".repeat(64)),716);717afterEach(() => vi.unstubAllEnvs());718