@hyodotdev/openiap-commerce-protocol 0.0.0-bootstrap.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CONVENTION.md +168 -0
  2. package/DESIGN.md +1056 -0
  3. package/README.md +227 -5
  4. package/SPEC.md +1471 -0
  5. package/conformance/index.d.ts +303 -0
  6. package/conformance/index.mjs +2126 -0
  7. package/conformance/mock-provider.mjs +491 -0
  8. package/examples/entitlement-granted-no-subscription.json +12 -0
  9. package/examples/entitlement-revoked.json +21 -0
  10. package/examples/provider-capabilities.json +209 -0
  11. package/examples/store-event-mapping.json +287 -0
  12. package/examples/subscription-canceled.json +22 -0
  13. package/examples/subscription-product-changed.json +30 -0
  14. package/examples/subscription-renewed.json +29 -0
  15. package/examples/verify-purchase-request.json +6 -0
  16. package/examples/verify-purchase-result.json +7 -0
  17. package/generated/bindings/graphql-operations.json +87 -0
  18. package/generated/bindings/http-binding.json +143 -0
  19. package/generated/bindings/introspection-signature.json +320 -0
  20. package/generated/bindings/operations-sdl.json +4 -0
  21. package/generated/bindings/operations.graphql +366 -0
  22. package/generated/commerce-protocol.graphql +1219 -0
  23. package/generated/openapi/commerce-protocol.openapi.json +1413 -0
  24. package/generated/schemas/commerce-event.schema.json +499 -0
  25. package/generated/schemas/commerce-protocol.bundle.schema.json +1576 -0
  26. package/generated/schemas/operations.schema.json +578 -0
  27. package/generated/schemas/primitives.schema.json +101 -0
  28. package/generated/schemas/provider-capabilities.schema.json +205 -0
  29. package/generated/schemas/store-event-mapping.schema.json +211 -0
  30. package/generated/vectors/lifecycle.json +908 -0
  31. package/generated/vectors/operations.json +1122 -0
  32. package/package.json +62 -12
  33. package/schema/01-primitives.graphql +102 -0
  34. package/schema/02-commerce-event.graphql +195 -0
  35. package/schema/03-provider-capabilities.graphql +139 -0
  36. package/schema/04-store-event-mapping.graphql +98 -0
  37. package/schema/05-operations.graphql +461 -0
  38. package/schema/06-compiler-vocabulary.graphql +139 -0
  39. package/schema/07-protocol-metadata.graphql +76 -0
  40. package/src/index.d.ts +63 -0
  41. package/src/index.mjs +121 -0
  42. package/vectors/signatures.json +139 -0
@@ -0,0 +1,491 @@
1
+ // Minimal in-memory provider implementing both transport bindings of the
2
+ // operation surface. It exists to prove the conformance runner judges against
3
+ // the specification alone: it imports no backend and none of IAPKit, and its
4
+ // data is fixture data — including its capability descriptor, which describes
5
+ // this mock, not any real store integration.
6
+ //
7
+ // Its GraphQL endpoint is a real executor over the generated schema projection:
8
+ // it parses, validates, and executes the query with graphql-js, so a runner
9
+ // that sends a real GraphQL document (not just an operationName) is genuinely
10
+ // exercised. graphql is a development-only dependency of this spec package; a
11
+ // non-JS provider would serve its own GraphQL runtime.
12
+
13
+ import { readFileSync } from "node:fs";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ import { buildSchema, execute, GraphQLError, parse, validate } from "graphql";
17
+
18
+ import {
19
+ httpBindingManifest,
20
+ lifecycleVectors,
21
+ operationVectors,
22
+ } from "./index.mjs";
23
+
24
+ const operationsSchema = JSON.parse(
25
+ readFileSync(
26
+ fileURLToPath(
27
+ new URL("../generated/schemas/operations.schema.json", import.meta.url),
28
+ ),
29
+ "utf8",
30
+ ),
31
+ );
32
+
33
+ const projectionSdl = JSON.parse(
34
+ readFileSync(
35
+ fileURLToPath(
36
+ new URL("../generated/bindings/operations-sdl.json", import.meta.url),
37
+ ),
38
+ "utf8",
39
+ ),
40
+ ).sdl;
41
+ const mockGraphqlSchema = buildSchema(projectionSdl);
42
+
43
+ /** First operation field of the (single) operation, through fragment spreads. */
44
+ function rootFieldName(document) {
45
+ const operation = document.definitions.find(
46
+ (definition) => definition.kind === "OperationDefinition",
47
+ );
48
+ if (!operation) return null;
49
+ const fragments = new Map(
50
+ document.definitions
51
+ .filter((definition) => definition.kind === "FragmentDefinition")
52
+ .map((definition) => [definition.name.value, definition.selectionSet]),
53
+ );
54
+ const seen = new Set();
55
+ const find = (selectionSet) => {
56
+ for (const selection of selectionSet?.selections ?? []) {
57
+ if (selection.kind === "Field") return selection.name.value;
58
+ if (selection.kind === "InlineFragment") {
59
+ const found = find(selection.selectionSet);
60
+ if (found) return found;
61
+ } else if (selection.kind === "FragmentSpread") {
62
+ const name = selection.name.value;
63
+ if (seen.has(name)) continue;
64
+ seen.add(name);
65
+ const found = find(fragments.get(name));
66
+ if (found) return found;
67
+ }
68
+ }
69
+ return null;
70
+ };
71
+ return find(operation.selectionSet);
72
+ }
73
+
74
+ const FIXTURES = operationVectors.fixtures;
75
+ const CREDENTIALS = Object.freeze({
76
+ verification: "mock-verification-credential",
77
+ server: "mock-server-credential",
78
+ });
79
+ const KNOWN_STORES = new Set(["apple", "google", "horizon", "amazon"]);
80
+
81
+ const fullSupport = () => ({ provider: true, implementation: true });
82
+ const mockedSupport = () => ({
83
+ provider: true,
84
+ implementation: false,
85
+ notes: "Fixture descriptor: the mock provider consumes no real store API.",
86
+ });
87
+
88
+ const CAPABILITIES = Object.freeze({
89
+ specVersion: httpBindingManifest.protocolVersion,
90
+ implementation: {
91
+ name: "openiap-conformance-mock-provider",
92
+ version: "0.1.0",
93
+ },
94
+ eventTypes: ["entitlement.granted", "entitlement.revoked"],
95
+ stores: {
96
+ apple: {
97
+ initialValidation: fullSupport(),
98
+ serverNotifications: mockedSupport(),
99
+ subscriptions: fullSupport(),
100
+ renewalEvents: mockedSupport(),
101
+ refundEvents: mockedSupport(),
102
+ expiration: mockedSupport(),
103
+ reconciliation: mockedSupport(),
104
+ entitlements: fullSupport(),
105
+ revenueAmount: mockedSupport(),
106
+ },
107
+ },
108
+ profiles: {
109
+ verification: "1.0",
110
+ entitlements: "1.0",
111
+ accountLifecycle: "1.0",
112
+ },
113
+ bindings: { rest: "1.0", graphql: "1.0" },
114
+ });
115
+
116
+ function requiredMembersOf(typeName) {
117
+ return operationsSchema.$defs[typeName]?.required ?? [];
118
+ }
119
+
120
+ class OperationError extends Error {
121
+ constructor(code, message) {
122
+ super(message);
123
+ this.code = code;
124
+ }
125
+ }
126
+
127
+ export function createMockProvider({ declareEvents = false } = {}) {
128
+ // The base mock serves the operation surface only. `declareEvents` adds the
129
+ // events profile to its descriptor so the runner's events verification (which
130
+ // requires an events adapter and reproduces the signature vectors) is
131
+ // exercised — a provider that declares events but skips signing must fail.
132
+ const capabilities = declareEvents
133
+ ? {
134
+ ...CAPABILITIES,
135
+ profiles: { ...CAPABILITIES.profiles, events: "1.0" },
136
+ // §10 honesty: everything the emission rules can produce must be
137
+ // declared — the runner cross-checks emitted types against this list.
138
+ eventTypes: [
139
+ ...new Set([
140
+ ...lifecycleVectors.emission.cases.flatMap(
141
+ (testCase) => testCase.emit,
142
+ ),
143
+ ...lifecycleVectors.binding.cases.flatMap(
144
+ (testCase) => testCase.emit,
145
+ ),
146
+ ]),
147
+ ].sort(),
148
+ }
149
+ : CAPABILITIES;
150
+ const subscriptions = [
151
+ {
152
+ productId: "mock.premium",
153
+ state: "Active",
154
+ active: true,
155
+ store: "google",
156
+ expiresAt: Date.now() + 30 * 86_400_000,
157
+ willRenew: true,
158
+ startedAt: Date.now() - 86_400_000,
159
+ updatedAt: Date.now(),
160
+ purchaseToken: FIXTURES.googlePurchaseToken,
161
+ userId: FIXTURES.userId,
162
+ },
163
+ ];
164
+ let erasureJobCounter = 0;
165
+ const erasureJobs = new Map();
166
+
167
+ const snapshotOf = (subscription) => ({
168
+ productId: subscription.productId,
169
+ state: subscription.state,
170
+ active: subscription.active,
171
+ store: subscription.store,
172
+ expiresAt: subscription.expiresAt,
173
+ willRenew: subscription.willRenew,
174
+ startedAt: subscription.startedAt,
175
+ updatedAt: subscription.updatedAt,
176
+ });
177
+
178
+ const requireInputMembers = (typeName, input) => {
179
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
180
+ throw new OperationError("INVALID_REQUEST", "input must be an object");
181
+ }
182
+ for (const member of requiredMembersOf(typeName)) {
183
+ if (input[member] === undefined || input[member] === null) {
184
+ throw new OperationError("INVALID_REQUEST", `${member} is required`);
185
+ }
186
+ }
187
+ };
188
+
189
+ const evidenceOf = (input) => {
190
+ if (!KNOWN_STORES.has(input.store)) {
191
+ throw new OperationError(
192
+ "UNSUPPORTED_STORE",
193
+ "This provider does not integrate the named store",
194
+ );
195
+ }
196
+ const evidence = input[input.store];
197
+ if (evidence === undefined || evidence === null) {
198
+ throw new OperationError(
199
+ "INVALID_REQUEST",
200
+ `${input.store} evidence is required`,
201
+ );
202
+ }
203
+ requireInputMembers(
204
+ `${input.store.charAt(0).toUpperCase()}${input.store.slice(1)}Evidence`,
205
+ evidence,
206
+ );
207
+ return evidence;
208
+ };
209
+
210
+ const handlers = {
211
+ providerCapabilities: () => capabilities,
212
+ subscriptionStatus: (input) => {
213
+ requireInputMembers("SubscriptionStatusInput", input);
214
+ const owned = subscriptions.filter(
215
+ (subscription) => subscription.userId === input.userId,
216
+ );
217
+ const active = owned.filter((subscription) => subscription.active);
218
+ const selected = active[0] ?? owned[0];
219
+ return {
220
+ active: active.length > 0,
221
+ ...(selected ? { subscription: snapshotOf(selected) } : {}),
222
+ };
223
+ },
224
+ entitlements: (input) => {
225
+ requireInputMembers("EntitlementsInput", input);
226
+ const active = subscriptions.filter(
227
+ (subscription) =>
228
+ subscription.userId === input.userId && subscription.active,
229
+ );
230
+ return {
231
+ userId: input.userId,
232
+ productIds: [...new Set(active.map((s) => s.productId))],
233
+ subscriptions: active.map(snapshotOf),
234
+ };
235
+ },
236
+ verifyPurchase: (input) => {
237
+ requireInputMembers("VerifyPurchaseInput", input);
238
+ evidenceOf(input);
239
+ return {
240
+ store: input.store,
241
+ isValid: true,
242
+ state: "ENTITLED",
243
+ productId: "mock.premium",
244
+ environment: "sandbox",
245
+ };
246
+ },
247
+ bindPurchase: (input) => {
248
+ requireInputMembers("BindPurchaseInput", input);
249
+ const evidence = evidenceOf(input);
250
+ const token =
251
+ evidence.purchaseToken ?? evidence.jws ?? evidence.receiptId;
252
+ const match = subscriptions.find(
253
+ (subscription) => subscription.purchaseToken === token,
254
+ );
255
+ if (!match) return { bound: false };
256
+ if (match.userId === undefined) {
257
+ match.userId = input.userId;
258
+ return { bound: true };
259
+ }
260
+ // Possession of a token is not proof of ownership: an existing binding
261
+ // never moves, and a foreign binding is indistinguishable from an
262
+ // unknown purchase.
263
+ return { bound: match.userId === input.userId };
264
+ },
265
+ eraseUser: (input) => {
266
+ requireInputMembers("EraseUserInput", input);
267
+ let job = erasureJobs.get(input.userId);
268
+ if (!job) {
269
+ erasureJobCounter += 1;
270
+ job = { jobId: `mock-erasure-job-${erasureJobCounter}` };
271
+ erasureJobs.set(input.userId, job);
272
+ for (const subscription of subscriptions) {
273
+ if (subscription.userId === input.userId) {
274
+ subscription.userId = undefined;
275
+ }
276
+ }
277
+ }
278
+ return { accepted: true, jobId: job.jobId, status: "completed" };
279
+ },
280
+ };
281
+
282
+ const roleOf = (request) => {
283
+ const header = request.headers.get?.("Authorization");
284
+ if (!header?.startsWith("Bearer ")) return null;
285
+ const token = header.slice("Bearer ".length);
286
+ if (token === CREDENTIALS.server) return "server";
287
+ if (token === CREDENTIALS.verification) return "verification";
288
+ return "invalid";
289
+ };
290
+
291
+ const authorize = (definition, role) => {
292
+ if (definition.auth === "none") return;
293
+ if (role === null || role === "invalid") {
294
+ throw new OperationError("UNAUTHORIZED", "A credential is required");
295
+ }
296
+ if (definition.auth === "server" && role !== "server") {
297
+ throw new OperationError(
298
+ "FORBIDDEN",
299
+ "This operation requires the server role",
300
+ );
301
+ }
302
+ };
303
+
304
+ const run = (operationName, input, role) => {
305
+ const definition = httpBindingManifest.operations.find(
306
+ (operation) => operation.name === operationName,
307
+ );
308
+ if (!definition) {
309
+ throw new OperationError("NOT_FOUND", "Unknown operation");
310
+ }
311
+ authorize(definition, role);
312
+ return {
313
+ definition,
314
+ data: handlers[operationName](input),
315
+ };
316
+ };
317
+
318
+ // Root resolvers for the real GraphQL executor: same auth + handlers as REST,
319
+ // protocol codes carried in errors[].extensions.code (HTTP stays 200).
320
+ const graphqlRootValue = (role) =>
321
+ Object.fromEntries(
322
+ httpBindingManifest.operations.map((operation) => [
323
+ operation.name,
324
+ (args) => {
325
+ try {
326
+ return run(operation.name, args?.input ?? null, role).data;
327
+ } catch (error) {
328
+ const code =
329
+ error instanceof OperationError ? error.code : "INTERNAL_ERROR";
330
+ throw new GraphQLError(
331
+ error instanceof Error ? error.message : "Operation failed",
332
+ { extensions: { code } },
333
+ );
334
+ }
335
+ },
336
+ ]),
337
+ );
338
+
339
+ const json = (status, body) =>
340
+ new Response(JSON.stringify(body), {
341
+ status,
342
+ headers: { "Content-Type": "application/json" },
343
+ });
344
+
345
+ const fetchImpl = async (url, options = {}) => {
346
+ const request = new Request(url, options);
347
+ const { pathname, searchParams } = new URL(request.url);
348
+ const role = roleOf(request);
349
+
350
+ if (pathname === "/commerce/v1/graphql" && request.method === "POST") {
351
+ let payload;
352
+ try {
353
+ payload = await request.json();
354
+ } catch {
355
+ // SPEC.md 7: a coded error MUST be HTTP 200, so a 400 transport-shape
356
+ // rejection stays codeless — the caller treats it as INVALID_REQUEST.
357
+ return json(400, {
358
+ errors: [{ message: "Request body is not JSON" }],
359
+ });
360
+ }
361
+ const { query, variables, operationName } = payload ?? {};
362
+ if (typeof query !== "string") {
363
+ return json(400, {
364
+ errors: [{ message: "query is required" }],
365
+ });
366
+ }
367
+ let document;
368
+ try {
369
+ document = parse(query);
370
+ } catch {
371
+ // graphql-js messages echo document text; fixed text per SPEC.md 8.
372
+ return json(200, {
373
+ errors: [
374
+ {
375
+ message: "The GraphQL document does not parse",
376
+ extensions: { code: "INVALID_REQUEST" },
377
+ },
378
+ ],
379
+ });
380
+ }
381
+ const validationErrors = validate(mockGraphqlSchema, document);
382
+ if (validationErrors.length) {
383
+ // A request-level failure is HTTP 200/400; literal-coercion messages
384
+ // echo submitted values, so the text is fixed.
385
+ return json(200, {
386
+ errors: validationErrors.map(() => ({
387
+ message: "The GraphQL document is not valid for the schema",
388
+ extensions: { code: "INVALID_REQUEST" },
389
+ })),
390
+ });
391
+ }
392
+ // SPEC.md 5: server-role authorization precedes input validation, and
393
+ // graphql-js coerces variables before any resolver — authorize the root
394
+ // operation field here, before execute().
395
+ const rootField = rootFieldName(document);
396
+ const rootDefinition = httpBindingManifest.operations.find(
397
+ (operation) => operation.name === rootField,
398
+ );
399
+ if (rootDefinition && rootDefinition.auth === "server") {
400
+ try {
401
+ authorize(rootDefinition, role);
402
+ } catch (error) {
403
+ return json(200, {
404
+ errors: [
405
+ {
406
+ message: error.message,
407
+ extensions: {
408
+ code:
409
+ error instanceof OperationError
410
+ ? error.code
411
+ : "INTERNAL_ERROR",
412
+ },
413
+ },
414
+ ],
415
+ });
416
+ }
417
+ }
418
+ const result = await execute({
419
+ schema: mockGraphqlSchema,
420
+ document,
421
+ rootValue: graphqlRootValue(role),
422
+ operationName: typeof operationName === "string" ? operationName : null,
423
+ variableValues:
424
+ variables && typeof variables === "object" ? variables : undefined,
425
+ });
426
+ return json(200, {
427
+ ...(result.data === undefined ? {} : { data: result.data }),
428
+ ...(result.errors?.length
429
+ ? {
430
+ errors: result.errors.map((error) => {
431
+ // A resolver-raised failure carries a protocol code and a safe
432
+ // message; a codeless error is graphql-js variable coercion,
433
+ // whose message echoes the submitted value — replace it.
434
+ const coded = typeof error.extensions?.code === "string";
435
+ return {
436
+ message: coded
437
+ ? error.message
438
+ : "The request variables are not valid for the operation",
439
+ extensions: {
440
+ code: coded ? error.extensions.code : "INVALID_REQUEST",
441
+ },
442
+ };
443
+ }),
444
+ }
445
+ : {}),
446
+ });
447
+ }
448
+
449
+ const definition = httpBindingManifest.operations.find(
450
+ (operation) =>
451
+ operation.path === pathname && operation.method === request.method,
452
+ );
453
+ if (!definition) {
454
+ return json(404, {
455
+ error: { code: "NOT_FOUND", message: "Unknown operation" },
456
+ });
457
+ }
458
+ let input = null;
459
+ if (definition.method === "GET") {
460
+ input = Object.fromEntries(searchParams.entries());
461
+ if (Object.keys(input).length === 0) input = definition.input ? {} : null;
462
+ } else {
463
+ try {
464
+ input = await request.json();
465
+ } catch {
466
+ return json(400, {
467
+ error: { code: "INVALID_REQUEST", message: "Body is not JSON" },
468
+ });
469
+ }
470
+ }
471
+ try {
472
+ const { data } = run(definition.name, input, role);
473
+ return json(definition.successStatus, data);
474
+ } catch (error) {
475
+ if (error instanceof OperationError) {
476
+ return json(httpBindingManifest.errorStatus[error.code] ?? 500, {
477
+ error: { code: error.code, message: error.message },
478
+ });
479
+ }
480
+ return json(500, {
481
+ error: { code: "INTERNAL_ERROR", message: "Operation failed" },
482
+ });
483
+ }
484
+ };
485
+
486
+ return {
487
+ fetch: fetchImpl,
488
+ credentials: { ...CREDENTIALS },
489
+ capabilities,
490
+ };
491
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "eventId": "evt_01J8Z8E7X5A2B4C6D8F0H2K4M6",
3
+ "eventType": "entitlement.granted",
4
+ "eventVersion": "1.0",
5
+ "occurredAt": 1756300800000,
6
+ "processedAt": 1756300800450,
7
+ "store": "horizon",
8
+ "environment": "production",
9
+ "projectId": "proj_7f3a9c",
10
+ "userId": "1234567890123456",
11
+ "productId": "premium.lifetime"
12
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "eventId": "evt_01J8Z6C4T1W6X9Y2E4H7L9N1B3",
3
+ "eventType": "entitlement.revoked",
4
+ "eventVersion": "1.0",
5
+ "occurredAt": 1758979200000,
6
+ "processedAt": 1758979203050,
7
+ "store": "apple",
8
+ "environment": "production",
9
+ "projectId": "proj_7f3a9c",
10
+ "userId": "user_5e91a7",
11
+ "productId": "premium.monthly",
12
+ "subscription": {
13
+ "state": "Expired",
14
+ "productId": "premium.monthly",
15
+ "expiresAt": 1758979200000,
16
+ "willRenew": false,
17
+ "active": false
18
+ },
19
+ "sourceStoreEventId": "a5d3f2b1-6c7e-8d9f-0a1b-2c3d4e5f6071",
20
+ "originalTransactionId": "2000000811111111"
21
+ }