@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,2126 @@
1
+ // Portable conformance runner for the OpenIAP Commerce Protocol operation
2
+ // surface. It drives any provider through the generated operation vectors,
3
+ // over REST, GraphQL, or both, and judges only against generated artifacts —
4
+ // never against any particular backend.
5
+ //
6
+ // It runs offline against whatever `fetch` it is given, needs no hosted
7
+ // service, and imports no implementation. The one external need is a JSON
8
+ // Schema validator: pass the Ajv 2020 class (`import Ajv from
9
+ // "ajv/dist/2020.js"`) as `Ajv` — the published runtime itself keeps zero
10
+ // dependencies, so it does not bundle one.
11
+
12
+ import { readFileSync } from "node:fs";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const load = (name) =>
16
+ JSON.parse(
17
+ readFileSync(
18
+ fileURLToPath(new URL(`../generated/${name}`, import.meta.url)),
19
+ "utf8",
20
+ ),
21
+ );
22
+
23
+ export const httpBindingManifest = load("bindings/http-binding.json");
24
+ export const graphqlOperations = load("bindings/graphql-operations.json");
25
+ export const introspectionSignature = load(
26
+ "bindings/introspection-signature.json",
27
+ );
28
+ export const operationVectors = load("vectors/operations.json");
29
+ export const signatureVectors = JSON.parse(
30
+ readFileSync(
31
+ fileURLToPath(new URL("../vectors/signatures.json", import.meta.url)),
32
+ "utf8",
33
+ ),
34
+ );
35
+ export const lifecycleVectors = load("vectors/lifecycle.json");
36
+ const bundleSchema = JSON.parse(
37
+ readFileSync(
38
+ fileURLToPath(
39
+ new URL(
40
+ "../generated/schemas/commerce-protocol.bundle.schema.json",
41
+ import.meta.url,
42
+ ),
43
+ ),
44
+ "utf8",
45
+ ),
46
+ );
47
+
48
+ const operationsByName = new Map(
49
+ httpBindingManifest.operations.map((operation) => [
50
+ operation.name,
51
+ operation,
52
+ ]),
53
+ );
54
+
55
+ /** A bearer this map does not name; a conforming provider must reject it. */
56
+ const INVALID_CREDENTIAL = "openiap-conformance-invalid-credential";
57
+
58
+ function stringLeaves(value, found = []) {
59
+ if (Array.isArray(value)) {
60
+ for (const item of value) stringLeaves(item, found);
61
+ return found;
62
+ }
63
+ if (typeof value === "string") {
64
+ found.push(value);
65
+ return found;
66
+ }
67
+ if (value === null || typeof value !== "object") return found;
68
+ for (const member of Object.values(value)) {
69
+ stringLeaves(member, found);
70
+ }
71
+ return found;
72
+ }
73
+
74
+ /** Every string nested inside a store-evidence object is sensitive. */
75
+ function evidenceValues(input) {
76
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
77
+ return [];
78
+ }
79
+ const found = [];
80
+ for (const [memberName, member] of Object.entries(input)) {
81
+ // Operation-level identity and the store discriminator are not evidence.
82
+ // Evidence members are nested objects; scanning every such object also
83
+ // covers a mismatched/future evidence member without a hard-coded list.
84
+ if (
85
+ memberName !== "userId" &&
86
+ memberName !== "store" &&
87
+ member !== null &&
88
+ typeof member === "object"
89
+ ) {
90
+ stringLeaves(member, found);
91
+ }
92
+ }
93
+ return found;
94
+ }
95
+
96
+ /**
97
+ * The exact shape SPEC.md 7 allows for a request-level failure: a non-empty
98
+ * errors array where every error has a message and no code beyond the
99
+ * generic INVALID_REQUEST, NO data member at all (the request never
100
+ * executed), HTTP 200 — or 400 only when every error is codeless. Anything
101
+ * looser would certify what §7 forbids: a more specific code on a request
102
+ * error, a coded error at 400, or a data member on a request that never ran.
103
+ */
104
+ function isWellFormedRequestRejection(status, body) {
105
+ if (!body || typeof body !== "object" || "data" in body) return false;
106
+ if (!Array.isArray(body.errors) || body.errors.length === 0) return false;
107
+ let codedCount = 0;
108
+ for (const error of body.errors) {
109
+ // Non-empty: SPEC.md 8 makes a message human-readable, and an empty
110
+ // string is not a message at all.
111
+ if (typeof error?.message !== "string" || error.message.length === 0) {
112
+ return false;
113
+ }
114
+ const code = error?.extensions?.code;
115
+ if (code !== undefined) {
116
+ if (code !== "INVALID_REQUEST") return false;
117
+ codedCount += 1;
118
+ }
119
+ }
120
+ // §7 categories are exclusive per envelope: all coded or all codeless —
121
+ // a mixed rejection is malformed, not a well-formed request rejection.
122
+ if (codedCount > 0 && codedCount < body.errors.length) return false;
123
+ return status === 200 || (status === 400 && codedCount === 0);
124
+ }
125
+
126
+ /**
127
+ * Members that may never appear in a server-read response (tokenless rule).
128
+ * SubscriptionStatusSnapshot forbids four categories: purchase tokens, store
129
+ * transaction identity, signed receipts, and provider-internal record ids.
130
+ */
131
+ const TOKEN_MEMBER_NAMES = new Set([
132
+ "purchaseToken",
133
+ "originalTransactionId",
134
+ "transactionId",
135
+ "jws",
136
+ "receipt",
137
+ "receiptId",
138
+ "signedTransaction",
139
+ "signedPayload",
140
+ "id",
141
+ "_id",
142
+ ]);
143
+
144
+ function findTokenMembers(value, path = "", found = []) {
145
+ if (Array.isArray(value)) {
146
+ value.forEach((item, index) =>
147
+ findTokenMembers(item, `${path}[${index}]`, found),
148
+ );
149
+ return found;
150
+ }
151
+ if (value === null || typeof value !== "object") return found;
152
+ for (const [key, member] of Object.entries(value)) {
153
+ const memberPath = path ? `${path}.${key}` : key;
154
+ if (TOKEN_MEMBER_NAMES.has(key)) found.push(memberPath);
155
+ findTokenMembers(member, memberPath, found);
156
+ }
157
+ return found;
158
+ }
159
+
160
+ /**
161
+ * GraphQL returns null for a selected member the provider omitted, and the
162
+ * operation types deliberately never allow a meaningful null — so dropping
163
+ * null members yields the binding-neutral shape both transports are judged
164
+ * on.
165
+ */
166
+ export function normalizeResultData(value) {
167
+ if (Array.isArray(value)) return value.map(normalizeResultData);
168
+ if (value === null || typeof value !== "object") return value;
169
+ // Null-prototype output: a provider member literally named __proto__ must
170
+ // stay an ordinary own key, not silently re-parent the object and vanish
171
+ // from parity, schema validation, and the tokenless scan.
172
+ const out = Object.create(null);
173
+ for (const [key, member] of Object.entries(value)) {
174
+ if (member === null || member === undefined) continue;
175
+ out[key] = normalizeResultData(member);
176
+ }
177
+ return out;
178
+ }
179
+
180
+ function stableStringify(value) {
181
+ if (Array.isArray(value)) {
182
+ return `[${value.map(stableStringify).join(",")}]`;
183
+ }
184
+ if (value === null || typeof value !== "object") {
185
+ return JSON.stringify(value);
186
+ }
187
+ const entries = Object.keys(value)
188
+ .sort()
189
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`);
190
+ return `{${entries.join(",")}}`;
191
+ }
192
+
193
+ function withoutMembers(value, members) {
194
+ if (!members?.length || value === null || typeof value !== "object") {
195
+ return value;
196
+ }
197
+ const out = { ...value };
198
+ for (const member of members) delete out[member];
199
+ return out;
200
+ }
201
+
202
+ /**
203
+ * Recursively keeps only the members the generated canonical selection tree
204
+ * names (`true` marks a leaf, an object a nested selection). Used for parity:
205
+ * the tree is this protocol version's contract shape, and a REST body may
206
+ * legally carry additive MINOR members on an open object that the frozen
207
+ * GraphQL selection cannot fetch — those fall outside the comparison, while a
208
+ * contract member missing from either binding still disagrees.
209
+ */
210
+ function projectOnto(value, tree) {
211
+ if (tree === true || tree === null || typeof tree !== "object") {
212
+ return value;
213
+ }
214
+ if (Array.isArray(value)) {
215
+ // A list field's tree describes each element.
216
+ return value.map((item) => projectOnto(item, tree));
217
+ }
218
+ if (value !== null && typeof value === "object") {
219
+ // Object.hasOwn, not `in`: the tree is a plain literal, so `in` would
220
+ // also match prototype names ("toString", "constructor") and wrongly
221
+ // keep a legal additive member that happens to collide with one.
222
+ const out = Object.create(null);
223
+ for (const key of Object.keys(value)) {
224
+ if (Object.hasOwn(tree, key)) {
225
+ out[key] = projectOnto(value[key], tree[key]);
226
+ }
227
+ }
228
+ return out;
229
+ }
230
+ return value;
231
+ }
232
+
233
+ /**
234
+ * Members present in `value` that the canonical selection tree never names.
235
+ * A real GraphQL executor cannot return a field the frozen document did not
236
+ * request, so anything extra on the GraphQL binding is a fabricated response
237
+ * — it must FAIL, never be silently projected away.
238
+ */
239
+ function extraMembers(value, tree, path = "", found = []) {
240
+ if (tree === true || tree === null || typeof tree !== "object") return found;
241
+ if (Array.isArray(value)) {
242
+ value.forEach((item, index) =>
243
+ extraMembers(item, tree, `${path}[${index}]`, found),
244
+ );
245
+ return found;
246
+ }
247
+ if (value === null || typeof value !== "object") return found;
248
+ for (const key of Object.keys(value)) {
249
+ const memberPath = path ? `${path}.${key}` : key;
250
+ if (!Object.hasOwn(tree, key)) {
251
+ found.push(memberPath);
252
+ } else {
253
+ extraMembers(value[key], tree[key], memberPath, found);
254
+ }
255
+ }
256
+ return found;
257
+ }
258
+
259
+ function authorizationHeader(credential, credentials) {
260
+ if (credential === null || credential === undefined) return {};
261
+ // A vector may present a credential no provider issued, to prove the
262
+ // provider rejects an unknown bearer rather than trusting any string.
263
+ if (credential === "invalid") {
264
+ return { Authorization: `Bearer ${INVALID_CREDENTIAL}` };
265
+ }
266
+ const value = credentials?.[credential];
267
+ if (!value) {
268
+ throw new Error(`No ${credential} credential was configured`);
269
+ }
270
+ return { Authorization: `Bearer ${value}` };
271
+ }
272
+
273
+ /**
274
+ * REST transport adapter: resolves each operation through the generated HTTP
275
+ * manifest and normalizes the response to a binding-neutral outcome.
276
+ */
277
+ export function createRestAdapter({ baseUrl, fetch: fetchFn, credentials }) {
278
+ if (!baseUrl || typeof fetchFn !== "function") {
279
+ throw new Error("createRestAdapter needs baseUrl and fetch");
280
+ }
281
+ const origin = baseUrl.replace(/\/$/u, "");
282
+ return {
283
+ binding: "rest",
284
+ // The configured bearer values, so the runner can reject an error
285
+ // message that echoes a credential (SPEC.md 8). Values only — never sent
286
+ // anywhere; compared against message text locally.
287
+ secrets: Object.values(credentials ?? {}),
288
+ async request({ operation, input, credential }) {
289
+ const definition = operationsByName.get(operation);
290
+ if (!definition) throw new Error(`Unknown operation: ${operation}`);
291
+ const headers = {
292
+ Accept: "application/json",
293
+ ...authorizationHeader(credential, credentials),
294
+ };
295
+ let url = `${origin}${definition.path}`;
296
+ const options = { method: definition.method, headers };
297
+ if (definition.method === "GET") {
298
+ const query = new URLSearchParams();
299
+ for (const [key, value] of Object.entries(input ?? {})) {
300
+ if (value !== null && value !== undefined) {
301
+ query.set(key, String(value));
302
+ }
303
+ }
304
+ const encoded = query.toString();
305
+ if (encoded) url += `?${encoded}`;
306
+ } else {
307
+ headers["Content-Type"] = "application/json";
308
+ options.body = JSON.stringify(input ?? {});
309
+ }
310
+ const response = await fetchFn(url, options);
311
+ const status = response.status;
312
+ let body;
313
+ try {
314
+ body = await response.json();
315
+ } catch {
316
+ return { kind: "invalid", status, detail: "response body is not JSON" };
317
+ }
318
+ if (status === definition.successStatus) {
319
+ return { kind: "result", status, data: normalizeResultData(body) };
320
+ }
321
+ const code = body?.error?.code;
322
+ if (
323
+ typeof code !== "string" ||
324
+ typeof body?.error?.message !== "string"
325
+ ) {
326
+ return {
327
+ kind: "invalid",
328
+ status,
329
+ detail: "error response is not a ProtocolErrorResponse",
330
+ };
331
+ }
332
+ // The full body rides along so the runner can validate the error
333
+ // envelope against the CLOSED ProtocolErrorResponse schema — a failure
334
+ // response is the easiest place to smuggle members past the tokenless
335
+ // rules, because callers rarely inspect one.
336
+ return { kind: "error", status, code, errorBody: body };
337
+ },
338
+ };
339
+ }
340
+
341
+ /**
342
+ * GraphQL transport adapter: sends the generated canonical full-selection
343
+ * document for each operation and normalizes the response. A GraphQL
344
+ * validation failure with no protocol code is INVALID_REQUEST by definition —
345
+ * see the GraphQL binding section of SPEC.md.
346
+ */
347
+ export function createGraphqlAdapter({ url, fetch: fetchFn, credentials }) {
348
+ if (!url || typeof fetchFn !== "function") {
349
+ throw new Error("createGraphqlAdapter needs url and fetch");
350
+ }
351
+ // Sends an arbitrary GraphQL request body — used by the executor probe to
352
+ // send malformed / non-canonical documents an operationName-only dispatcher
353
+ // would mishandle.
354
+ const rawGraphql = async (payload, credential) => {
355
+ const response = await fetchFn(url, {
356
+ method: "POST",
357
+ headers: {
358
+ "Content-Type": "application/json",
359
+ Accept: "application/json",
360
+ ...authorizationHeader(credential, credentials),
361
+ },
362
+ body: JSON.stringify(payload),
363
+ });
364
+ let body;
365
+ try {
366
+ body = await response.json();
367
+ } catch {
368
+ body = null;
369
+ }
370
+ return { status: response.status, body };
371
+ };
372
+
373
+ return {
374
+ binding: "graphql",
375
+ // See createRestAdapter: local credential-echo comparison only.
376
+ secrets: Object.values(credentials ?? {}),
377
+ rawGraphql,
378
+ async request({ operation, input, credential }) {
379
+ const entry = graphqlOperations.operations[operation];
380
+ if (!entry) throw new Error(`Unknown operation: ${operation}`);
381
+ const response = await fetchFn(url, {
382
+ method: "POST",
383
+ headers: {
384
+ "Content-Type": "application/json",
385
+ Accept: "application/json",
386
+ ...authorizationHeader(credential, credentials),
387
+ },
388
+ body: JSON.stringify({
389
+ query: entry.document,
390
+ operationName: operation.charAt(0).toUpperCase() + operation.slice(1),
391
+ variables: input === null || input === undefined ? {} : { input },
392
+ }),
393
+ });
394
+ let body;
395
+ try {
396
+ body = await response.json();
397
+ } catch {
398
+ return {
399
+ kind: "invalid",
400
+ status: response.status,
401
+ detail: "response body is not JSON",
402
+ };
403
+ }
404
+ if (Array.isArray(body?.errors) && body.errors.length) {
405
+ // EVERY entry in the envelope is held to the wrapper rules — judging
406
+ // errors[0] alone would let a provider smuggle a malformed or
407
+ // differently-coded error behind a clean first one.
408
+ const codes = body.errors.map((error) => error?.extensions?.code);
409
+ for (const error of body.errors) {
410
+ // SPEC.md 8: a message is human-readable — an absent or empty one
411
+ // is not a message at all (REST's NonEmptyString enforces the same).
412
+ if (
413
+ typeof error?.message !== "string" ||
414
+ error.message.length === 0
415
+ ) {
416
+ return {
417
+ kind: "invalid",
418
+ status: response.status,
419
+ detail:
420
+ "a GraphQL error in the envelope carries no non-empty message string",
421
+ };
422
+ }
423
+ }
424
+ // One definition of "coded" for the whole file: a present code that
425
+ // is not a string is malformed, never quietly treated as codeless.
426
+ if (
427
+ codes.some((code) => code !== undefined && typeof code !== "string")
428
+ ) {
429
+ return {
430
+ kind: "invalid",
431
+ status: response.status,
432
+ detail: "extensions.code must be a string when present",
433
+ };
434
+ }
435
+ // SPEC.md 7's two categories are exclusive per envelope: an operation
436
+ // failure carries the §8 code, a request-level failure carries none
437
+ // (or the generic INVALID_REQUEST on every entry). A codeless entry
438
+ // riding beside a coded one would be invisible to every code check —
439
+ // a leak channel — so a mixed envelope is malformed outright.
440
+ if (
441
+ codes.some((code) => typeof code === "string") &&
442
+ codes.some((code) => code === undefined)
443
+ ) {
444
+ return {
445
+ kind: "invalid",
446
+ status: response.status,
447
+ detail:
448
+ "the envelope mixes coded and codeless errors (SPEC.md 7 categories are exclusive)",
449
+ };
450
+ }
451
+ // SPEC.md 7: an operation failure — the error carries a protocol
452
+ // code — is an HTTP 200. A request-level failure carries no code
453
+ // (the caller treats it as INVALID_REQUEST), omits the data member
454
+ // entirely, and MAY use 200 or 400. A codeless error WITH a data
455
+ // member is an executed operation hiding its protocol code.
456
+ if (
457
+ codes.some((code) => typeof code === "string") &&
458
+ response.status !== 200
459
+ ) {
460
+ return {
461
+ kind: "invalid",
462
+ status: response.status,
463
+ detail: `GraphQL operation error returned HTTP ${response.status}, must be 200`,
464
+ };
465
+ }
466
+ if (codes.some((code) => typeof code !== "string") && "data" in body) {
467
+ return {
468
+ kind: "invalid",
469
+ status: response.status,
470
+ detail:
471
+ "an executed operation's error must carry a protocol code (SPEC.md 7)",
472
+ };
473
+ }
474
+ if (
475
+ codes.every((code) => typeof code !== "string") &&
476
+ response.status !== 200 &&
477
+ response.status !== 400
478
+ ) {
479
+ return {
480
+ kind: "invalid",
481
+ status: response.status,
482
+ detail: `GraphQL request error returned HTTP ${response.status}, must be 200 or 400`,
483
+ };
484
+ }
485
+ const stringCodes = codes.filter((value) => typeof value === "string");
486
+ return {
487
+ kind: "error",
488
+ status: response.status,
489
+ code: stringCodes[0] ?? "INVALID_REQUEST",
490
+ // ONE structure for the whole envelope — message and code travel
491
+ // together per entry, so a code cannot be dropped while its message
492
+ // is kept (or vice versa) to dodge half the checks. The runner
493
+ // derives everything it needs from this.
494
+ errors: body.errors.map((error) => ({
495
+ message: error.message,
496
+ ...(typeof error.extensions?.code === "string"
497
+ ? { code: error.extensions.code }
498
+ : {}),
499
+ })),
500
+ // SPEC.md 7: a pre-execution refusal omits data; vectors for
501
+ // server-role auth negatives assert on this.
502
+ hasData: "data" in body,
503
+ };
504
+ }
505
+ // The canonical document selects exactly one root field — a real
506
+ // executor cannot answer any other. A sibling root is fabricated data
507
+ // riding beside the operation, and extracting only data[operation]
508
+ // would silently discard it.
509
+ if (body?.data && typeof body.data === "object") {
510
+ const roots = Object.keys(body.data);
511
+ if (roots.length !== 1 || roots[0] !== operation) {
512
+ return {
513
+ kind: "invalid",
514
+ status: response.status,
515
+ detail: `GraphQL data carries roots [${roots.join(", ")}], the document requested only ${operation}`,
516
+ };
517
+ }
518
+ }
519
+ const data = body?.data?.[operation];
520
+ if (data === undefined || data === null) {
521
+ return {
522
+ kind: "invalid",
523
+ status: response.status,
524
+ detail: "response carries neither data nor errors",
525
+ };
526
+ }
527
+ if (response.status !== 200) {
528
+ return {
529
+ kind: "invalid",
530
+ status: response.status,
531
+ detail: `GraphQL success returned HTTP ${response.status}, must be 200`,
532
+ };
533
+ }
534
+ return {
535
+ kind: "result",
536
+ status: response.status,
537
+ data: normalizeResultData(data),
538
+ // PRE-normalization shape: the unrequested-member check must see a
539
+ // fabricated `member: null` before null-stripping erases it.
540
+ rawData: data,
541
+ };
542
+ },
543
+ };
544
+ }
545
+
546
+ function buildValidator(Ajv) {
547
+ if (typeof Ajv !== "function") {
548
+ throw new Error(
549
+ 'runConformance needs the Ajv 2020 class: import Ajv from "ajv/dist/2020.js"',
550
+ );
551
+ }
552
+ const ajv = new Ajv({
553
+ strict: true,
554
+ allErrors: true,
555
+ loadSchema: () => {
556
+ throw new Error("remote schema resolution attempted");
557
+ },
558
+ });
559
+ ajv.addSchema(bundleSchema, "bundle");
560
+ return (pointer) => ajv.getSchema(`bundle#/$defs/${pointer}`);
561
+ }
562
+
563
+ // A returned error must map to its manifest status on REST, must be one the
564
+ // operation actually declares, and — for a non-blocking code every operation
565
+ // can hit — must sit in a small always-allowed set. Applied to both the
566
+ // expected-error branch and the allowCodes bypass so neither escapes it.
567
+ const ALWAYS_ALLOWED_ERROR_CODES = new Set(["RATE_LIMITED", "INTERNAL_ERROR"]);
568
+
569
+ function checkErrorOutcome(outcome, operationName, adapter) {
570
+ const failures = [];
571
+ if (adapter.binding === "rest") {
572
+ const status = httpBindingManifest.errorStatus[outcome.code];
573
+ if (status !== undefined && outcome.status !== status) {
574
+ failures.push(
575
+ `REST status for ${outcome.code} must be ${status}, got ${outcome.status}`,
576
+ );
577
+ }
578
+ }
579
+ const declared = operationsByName.get(operationName)?.errors ?? [];
580
+ // Same derived-codes/empty-fallback rule as the expectation loop.
581
+ const derived =
582
+ adapter.binding === "graphql" ? graphqlErrorCodes(outcome) : [];
583
+ for (const code of derived.length ? derived : [outcome.code]) {
584
+ if (!declared.includes(code) && !ALWAYS_ALLOWED_ERROR_CODES.has(code)) {
585
+ failures.push(
586
+ `${operationName} returned ${code}, which it does not declare in the manifest`,
587
+ );
588
+ }
589
+ }
590
+ return failures;
591
+ }
592
+
593
+ // SPEC.md 4.2: the outer `active` gate must agree with the snapshot's own
594
+ // gate, and a `true` gate requires an entitling subscription. The schema
595
+ // cannot express this cross-field relation, so the runner checks it.
596
+ function statusInvariants(data) {
597
+ const failures = [];
598
+ if (!data || typeof data !== "object") return failures;
599
+ const active = data.active === true;
600
+ const subscriptionActive = data.subscription?.active === true;
601
+ if (active !== subscriptionActive) {
602
+ failures.push(
603
+ `active (${data.active}) disagrees with subscription.active (${data.subscription?.active})`,
604
+ );
605
+ }
606
+ if (active && !data.subscription) {
607
+ failures.push("active is true but no entitling subscription is present");
608
+ }
609
+ return failures;
610
+ }
611
+
612
+ // SPEC.md 4.3: the result answers FOR the requested user — an echoed userId
613
+ // that names someone else is a cross-user leak, whatever else matches —
614
+ // productIds must be exactly the deduplicated productIds of the returned
615
+ // subscriptions, and every returned subscription must be entitled.
616
+ function entitlementsInvariants(data, input) {
617
+ const failures = [];
618
+ if (!data || typeof data !== "object") return failures;
619
+ if (
620
+ typeof input?.userId === "string" &&
621
+ data.userId !== undefined &&
622
+ data.userId !== input.userId
623
+ ) {
624
+ failures.push(
625
+ `entitlements answered for userId ${JSON.stringify(data.userId)} but ${JSON.stringify(input.userId)} was requested`,
626
+ );
627
+ }
628
+ const subscriptions = Array.isArray(data.subscriptions)
629
+ ? data.subscriptions
630
+ : [];
631
+ for (const subscription of subscriptions) {
632
+ if (subscription?.active !== true) {
633
+ failures.push(
634
+ `entitlements returned a non-active subscription (${subscription?.productId})`,
635
+ );
636
+ }
637
+ }
638
+ const expected = [...new Set(subscriptions.map((s) => s?.productId))].sort();
639
+ const actual = Array.isArray(data.productIds)
640
+ ? [...data.productIds].sort()
641
+ : [];
642
+ if (stableStringify(expected) !== stableStringify(actual)) {
643
+ failures.push(
644
+ `productIds ${JSON.stringify(actual)} is not the deduplicated set of active subscription productIds ${JSON.stringify(expected)}`,
645
+ );
646
+ }
647
+ return failures;
648
+ }
649
+
650
+ /**
651
+ * SPEC.md 8 message hygiene, shared by the vector envelope checks AND the
652
+ * executor probes so neither becomes a side door: every error message is a
653
+ * non-empty string (human-readable) and echoes neither the submitted
654
+ * evidence nor a configured credential.
655
+ */
656
+ function messageHygieneFailures(messages, forbidden, context) {
657
+ const failures = [];
658
+ for (const message of messages) {
659
+ if (typeof message !== "string" || message.length === 0) {
660
+ failures.push(
661
+ `${context}: an error message must be a non-empty string (SPEC.md 8)`,
662
+ );
663
+ continue;
664
+ }
665
+ if (forbidden.some((token) => message.includes(token))) {
666
+ failures.push(
667
+ `${context}: an error message echoes submitted evidence or a credential (SPEC.md 8)`,
668
+ );
669
+ }
670
+ }
671
+ return failures;
672
+ }
673
+
674
+ /**
675
+ * Values that must never appear in an error message: this vector's own
676
+ * evidence, the AUTHORITATIVE credential values the caller handed the runner
677
+ * (an adapter-supplied list alone could be emptied to dodge the scan), any
678
+ * adapter-declared extras, and the runner's invalid-credential constant.
679
+ * Compared locally against message text only; nothing leaves the runner.
680
+ */
681
+ function forbiddenTokens({ input, adapter, credentials }) {
682
+ // Evidence values keep a minimum length so a short generic fragment can't
683
+ // false-positive against ordinary prose; credentials are the caller's
684
+ // EXACT configured values and are scanned at any length — a short real
685
+ // credential echoed into a message is still a leak.
686
+ return [
687
+ ...evidenceValues(input).filter(
688
+ (value) => typeof value === "string" && value.length >= 8,
689
+ ),
690
+ ...Object.values(credentials ?? {}),
691
+ ...(Array.isArray(adapter?.secrets) ? adapter.secrets : []),
692
+ INVALID_CREDENTIAL,
693
+ ].filter((value) => typeof value === "string" && value.length > 0);
694
+ }
695
+
696
+ /** Derived views over the unified GraphQL error structure. */
697
+ function graphqlErrorCodes(outcome) {
698
+ return Array.isArray(outcome.errors)
699
+ ? outcome.errors
700
+ .map((error) => error?.code)
701
+ .filter((code) => typeof code === "string")
702
+ : [];
703
+ }
704
+
705
+ /**
706
+ * Envelope rules that hold for EVERY error outcome, whichever expectation
707
+ * branch produced it — the adapter contract, the SPEC.md 8 message hygiene,
708
+ * and the closed REST error envelope. Factored out so the allowCodes branch
709
+ * (a success vector answered with a permitted error, e.g.
710
+ * VERIFICATION_FAILED without store credentials) cannot become a side door
711
+ * around them.
712
+ */
713
+ function errorEnvelopeFailures({
714
+ outcome,
715
+ adapter,
716
+ validate,
717
+ input,
718
+ credentials,
719
+ }) {
720
+ const failures = [];
721
+ // Adapter contract: this metadata is what the envelope rules run on. An
722
+ // adapter that omits or hollows it must fail LOUDLY here — checking mere
723
+ // presence would let `errors: []` or `rawData: undefined` pass vacuously.
724
+ if (adapter.binding === "graphql") {
725
+ if (
726
+ !Array.isArray(outcome.errors) ||
727
+ outcome.errors.length === 0 ||
728
+ typeof outcome.hasData !== "boolean"
729
+ ) {
730
+ failures.push(
731
+ "adapter contract: a GraphQL error outcome must carry a non-empty errors[] (each {message, code?}) and a boolean hasData — the envelope rules cannot run without them",
732
+ );
733
+ } else {
734
+ for (const error of outcome.errors) {
735
+ if (error?.code !== undefined && typeof error.code !== "string") {
736
+ failures.push(
737
+ "adapter contract: a GraphQL error entry's code must be a string when present",
738
+ );
739
+ }
740
+ }
741
+ // §7 rules stated over the REPORTED envelope too, not only the wire
742
+ // (createGraphqlAdapter checks the wire; a custom adapter never goes
743
+ // through it): the coded/codeless categories are exclusive, and a
744
+ // codeless envelope on an executed response hides its protocol code.
745
+ const codedCount = outcome.errors.filter(
746
+ (error) => typeof error?.code === "string",
747
+ ).length;
748
+ if (codedCount > 0 && codedCount < outcome.errors.length) {
749
+ failures.push(
750
+ "the envelope mixes coded and codeless errors (SPEC.md 7 categories are exclusive)",
751
+ );
752
+ }
753
+ if (codedCount === 0 && outcome.hasData === true) {
754
+ failures.push(
755
+ "an executed operation's error must carry a protocol code (SPEC.md 7)",
756
+ );
757
+ }
758
+ }
759
+ }
760
+ if (adapter.binding === "rest" && outcome.errorBody === undefined) {
761
+ failures.push(
762
+ "adapter contract: a REST error outcome must carry errorBody — the closed error envelope cannot be validated without it",
763
+ );
764
+ }
765
+ // SPEC.md 7's HTTP status rules live HERE, in the common helper, not only
766
+ // inside createGraphqlAdapter — a custom adapter reporting a coded error
767
+ // at 400 must fail whichever expectation branch evaluates it.
768
+ if (adapter.binding === "graphql" && Array.isArray(outcome.errors)) {
769
+ const coded = graphqlErrorCodes(outcome).length > 0;
770
+ if (coded && outcome.status !== 200) {
771
+ failures.push(
772
+ `SPEC.md 7: a coded GraphQL error must be delivered at HTTP 200, got ${outcome.status}`,
773
+ );
774
+ }
775
+ if (!coded && outcome.status !== 200 && outcome.status !== 400) {
776
+ failures.push(
777
+ `SPEC.md 7: a codeless request rejection must be HTTP 200 or 400, got ${outcome.status}`,
778
+ );
779
+ }
780
+ }
781
+ // The normalized outcome.code is DERIVED state — an adapter asserting a
782
+ // different code than its own reported envelope could steer the runner
783
+ // into the wrong expectation branch (e.g. fake VERIFICATION_FAILED to
784
+ // enter allowCodes). Cross-check it against the envelope on both bindings.
785
+ if (adapter.binding === "graphql" && Array.isArray(outcome.errors)) {
786
+ const derived = graphqlErrorCodes(outcome)[0] ?? "INVALID_REQUEST";
787
+ if (outcome.code !== derived) {
788
+ failures.push(
789
+ `adapter contract: outcome.code (${outcome.code}) disagrees with the reported errors[] (${derived}) — the normalized code must be derived, never asserted`,
790
+ );
791
+ }
792
+ }
793
+ if (
794
+ adapter.binding === "rest" &&
795
+ outcome.errorBody !== undefined &&
796
+ typeof outcome.errorBody?.error?.code === "string" &&
797
+ outcome.code !== outcome.errorBody.error.code
798
+ ) {
799
+ failures.push(
800
+ `adapter contract: outcome.code (${outcome.code}) disagrees with errorBody.error.code (${outcome.errorBody.error.code})`,
801
+ );
802
+ }
803
+ const forbidden = forbiddenTokens({ input, adapter, credentials });
804
+ const messages =
805
+ adapter.binding === "graphql"
806
+ ? Array.isArray(outcome.errors)
807
+ ? outcome.errors.map((error) => error?.message)
808
+ : []
809
+ : [outcome.errorBody?.error?.message].filter(
810
+ (message) => message !== undefined,
811
+ );
812
+ failures.push(
813
+ ...messageHygieneFailures(messages, forbidden, "error envelope"),
814
+ );
815
+ // SPEC.md 6: the REST error envelope is CLOSED — validate the whole body,
816
+ // so a leak attached beside `error` fails instead of riding out unseen.
817
+ if (outcome.errorBody !== undefined && validate) {
818
+ const validator = validate("ProtocolErrorResponse");
819
+ if (validator && !validator(outcome.errorBody)) {
820
+ failures.push(
821
+ `error envelope does not validate against ProtocolErrorResponse: ${JSON.stringify(validator.errors?.slice(0, 2))}`,
822
+ );
823
+ }
824
+ }
825
+ return failures;
826
+ }
827
+
828
+ function evaluateExpectation({
829
+ outcome,
830
+ expect,
831
+ operationName,
832
+ adapter,
833
+ validate,
834
+ input,
835
+ credentials,
836
+ }) {
837
+ const failures = [];
838
+ // Every code the envelope carried, derived from the unified structure. An
839
+ // EMPTY list falls back to the normalized outcome.code: a codeless
840
+ // server-auth rejection normalizes to INVALID_REQUEST, and that must fail
841
+ // a vector expecting UNAUTHORIZED, not skip the loop entirely.
842
+ const envelopeCodes = (candidate) => {
843
+ const derived =
844
+ adapter.binding === "graphql" ? graphqlErrorCodes(candidate) : [];
845
+ return derived.length ? derived : [candidate.code];
846
+ };
847
+ if (expect.kind === "error") {
848
+ if (outcome.kind !== "error") {
849
+ failures.push(
850
+ `expected an error, got ${outcome.kind}${outcome.detail ? `: ${outcome.detail}` : ""}`,
851
+ );
852
+ return failures;
853
+ }
854
+ failures.push(
855
+ ...errorEnvelopeFailures({
856
+ outcome,
857
+ adapter,
858
+ validate,
859
+ input,
860
+ credentials,
861
+ }),
862
+ );
863
+ for (const code of envelopeCodes(outcome)) {
864
+ if (!expect.codes.includes(code)) {
865
+ failures.push(
866
+ `expected one of [${expect.codes.join(", ")}], got ${code}`,
867
+ );
868
+ }
869
+ }
870
+ // SPEC.md 7: a pre-execution refusal (server-role auth, decided before
871
+ // the document executes) omits the data member entirely.
872
+ if (
873
+ expect.preExecution &&
874
+ adapter.binding === "graphql" &&
875
+ outcome.hasData
876
+ ) {
877
+ failures.push(
878
+ "a pre-execution refusal must omit the data member (SPEC.md 7)",
879
+ );
880
+ }
881
+ failures.push(...checkErrorOutcome(outcome, operationName, adapter));
882
+ return failures;
883
+ }
884
+ // The allowCodes gate keys on the DERIVED code, so an adapter cannot
885
+ // assert a permitted code to smuggle a different envelope into this branch.
886
+ const normalizedCode =
887
+ outcome.kind !== "error"
888
+ ? undefined
889
+ : adapter.binding === "graphql"
890
+ ? (graphqlErrorCodes(outcome)[0] ?? "INVALID_REQUEST")
891
+ : typeof outcome.errorBody?.error?.code === "string"
892
+ ? outcome.errorBody.error.code
893
+ : outcome.code;
894
+ if (outcome.kind === "error" && expect.allowCodes?.includes(normalizedCode)) {
895
+ // The verdict is unreachable without store credentials, but the error is
896
+ // still a full protocol error: its envelope, message hygiene, status,
897
+ // and declaration all stay contract — this branch is not a side door.
898
+ failures.push(
899
+ ...errorEnvelopeFailures({
900
+ outcome,
901
+ adapter,
902
+ validate,
903
+ input,
904
+ credentials,
905
+ }),
906
+ );
907
+ for (const code of envelopeCodes(outcome)) {
908
+ if (!expect.allowCodes.includes(code)) {
909
+ failures.push(
910
+ `expected one of [${expect.allowCodes.join(", ")}], got ${code}`,
911
+ );
912
+ }
913
+ }
914
+ failures.push(...checkErrorOutcome(outcome, operationName, adapter));
915
+ return failures;
916
+ }
917
+ if (outcome.kind !== "result") {
918
+ failures.push(
919
+ `expected a result, got ${outcome.kind}${outcome.code ? ` (${outcome.code})` : ""}${outcome.detail ? `: ${outcome.detail}` : ""}`,
920
+ );
921
+ return failures;
922
+ }
923
+ return failures;
924
+ }
925
+
926
+ function evaluateResultChecks({ outcome, expect, adapter, validate, input }) {
927
+ const failures = [];
928
+ if (outcome.kind !== "result") return failures;
929
+ if (expect.schema) {
930
+ const validator = validate(expect.schema);
931
+ if (!validator) {
932
+ failures.push(`no bundle definition for ${expect.schema}`);
933
+ } else if (!validator(outcome.data)) {
934
+ failures.push(
935
+ `result does not validate against ${expect.schema}: ${JSON.stringify(validator.errors?.slice(0, 3))}`,
936
+ );
937
+ }
938
+ }
939
+ if (expect.resultSubset) {
940
+ for (const [member, value] of Object.entries(expect.resultSubset)) {
941
+ const actual =
942
+ outcome.data && typeof outcome.data === "object"
943
+ ? outcome.data[member]
944
+ : undefined;
945
+ if (stableStringify(actual) !== stableStringify(value)) {
946
+ failures.push(
947
+ `result.${member} must equal ${JSON.stringify(value)}, got ${JSON.stringify(actual)}`,
948
+ );
949
+ }
950
+ }
951
+ }
952
+ for (const check of expect.checks ?? []) {
953
+ if (check === "tokenless") {
954
+ // Primary enforcement is the CLOSED result schema (validated above): a
955
+ // rawReceipt or provider-internal id fails validation, no name list
956
+ // required. This heuristic scan is defence-in-depth for the open parts
957
+ // of the response tree only.
958
+ const leaks = findTokenMembers(outcome.data);
959
+ if (leaks.length) {
960
+ failures.push(`token members in response: ${leaks.join(", ")}`);
961
+ }
962
+ } else if (check === "statusConsistency") {
963
+ failures.push(...statusInvariants(outcome.data));
964
+ } else if (check === "entitlementsConsistency") {
965
+ failures.push(...entitlementsInvariants(outcome.data, input));
966
+ } else if (check === "declaresTestedBinding") {
967
+ const bindings =
968
+ outcome.data && typeof outcome.data === "object"
969
+ ? outcome.data.bindings
970
+ : undefined;
971
+ if (typeof bindings?.[adapter.binding] !== "string") {
972
+ failures.push(
973
+ `capability descriptor does not declare the ${adapter.binding} binding it just answered on`,
974
+ );
975
+ }
976
+ } else {
977
+ failures.push(`unknown check: ${check}`);
978
+ }
979
+ }
980
+ return failures;
981
+ }
982
+
983
+ /**
984
+ * Probes the GraphQL binding as a real executor, not an operationName-only
985
+ * dispatcher. Sends documents a dispatcher that ignores `query` would mishandle
986
+ * — a parse error, a field the schema does not define, a variable of the wrong
987
+ * type — plus an introspection query that must agree with the projection.
988
+ */
989
+ /** Renders an introspection type reference back into SDL notation. */
990
+ function typeRefString(ref) {
991
+ if (!ref || typeof ref !== "object") return null;
992
+ if (ref.kind === "NON_NULL") {
993
+ const inner = typeRefString(ref.ofType);
994
+ return inner === null ? null : `${inner}!`;
995
+ }
996
+ if (ref.kind === "LIST") {
997
+ const inner = typeRefString(ref.ofType);
998
+ return inner === null ? null : `[${inner}]`;
999
+ }
1000
+ return typeof ref.name === "string" ? ref.name : null;
1001
+ }
1002
+
1003
+ /**
1004
+ * Compares a served __schema against the generated structural signature, as a
1005
+ * subset: every type, field, argument, input member, and enum value the
1006
+ * signature names must be served with the exact kind, type string (including
1007
+ * nullability), and — for closed enums and objects — the exact member set.
1008
+ * Extra types, fields on open objects, and NULLABLE arguments or input members
1009
+ * are compatible MINOR additions; an extra non-null argument or input member
1010
+ * would break existing callers and fails.
1011
+ */
1012
+ function compareIntrospection(servedSchema) {
1013
+ const failures = [];
1014
+ const servedTypes = new Map(
1015
+ (servedSchema?.types ?? [])
1016
+ .filter((type) => typeof type?.name === "string")
1017
+ .map((type) => [type.name, type]),
1018
+ );
1019
+ if (servedTypes.size === 0) {
1020
+ failures.push(
1021
+ "introspection neither returned the schema types nor rejected as a well-formed request-level failure",
1022
+ );
1023
+ return failures;
1024
+ }
1025
+ for (const [rootKind, expectedName] of [
1026
+ ["queryType", introspectionSignature.queryType],
1027
+ ["mutationType", introspectionSignature.mutationType],
1028
+ ]) {
1029
+ if (expectedName && servedSchema?.[rootKind]?.name !== expectedName) {
1030
+ failures.push(
1031
+ `introspection ${rootKind} is ${JSON.stringify(servedSchema?.[rootKind]?.name)}, the projection's is ${expectedName}`,
1032
+ );
1033
+ }
1034
+ }
1035
+ for (const [typeName, expected] of Object.entries(
1036
+ introspectionSignature.types,
1037
+ )) {
1038
+ const served = servedTypes.get(typeName);
1039
+ if (!served) {
1040
+ failures.push(`introspection is missing type ${typeName}`);
1041
+ continue;
1042
+ }
1043
+ if (served.kind !== expected.kind) {
1044
+ failures.push(
1045
+ `type ${typeName} is served as ${served.kind}, the projection defines ${expected.kind}`,
1046
+ );
1047
+ continue;
1048
+ }
1049
+ if (expected.kind === "OBJECT") {
1050
+ const servedFields = new Map(
1051
+ (served.fields ?? []).map((field) => [field.name, field]),
1052
+ );
1053
+ if (expected.closed) {
1054
+ for (const fieldName of servedFields.keys()) {
1055
+ if (!Object.hasOwn(expected.fields, fieldName)) {
1056
+ failures.push(
1057
+ `closed type ${typeName} serves undeclared field ${fieldName}`,
1058
+ );
1059
+ }
1060
+ }
1061
+ }
1062
+ for (const [fieldName, expectedField] of Object.entries(
1063
+ expected.fields,
1064
+ )) {
1065
+ const servedField = servedFields.get(fieldName);
1066
+ if (!servedField) {
1067
+ failures.push(`type ${typeName} is missing field ${fieldName}`);
1068
+ continue;
1069
+ }
1070
+ const servedType = typeRefString(servedField.type);
1071
+ if (servedType !== expectedField.type) {
1072
+ failures.push(
1073
+ `${typeName}.${fieldName} is served as ${servedType}, the projection defines ${expectedField.type}`,
1074
+ );
1075
+ }
1076
+ const servedArgs = new Map(
1077
+ (servedField.args ?? []).map((arg) => [arg.name, arg]),
1078
+ );
1079
+ for (const [argName, expectedArg] of Object.entries(
1080
+ expectedField.args ?? {},
1081
+ )) {
1082
+ const servedArg = servedArgs.get(argName);
1083
+ const servedArgType = servedArg
1084
+ ? typeRefString(servedArg.type)
1085
+ : null;
1086
+ if (servedArgType !== expectedArg) {
1087
+ failures.push(
1088
+ `${typeName}.${fieldName}(${argName}:) is served as ${servedArgType}, the projection defines ${expectedArg}`,
1089
+ );
1090
+ }
1091
+ }
1092
+ for (const [argName, servedArg] of servedArgs) {
1093
+ if (Object.hasOwn(expectedField.args ?? {}, argName)) continue;
1094
+ // An unrenderable ref (deeper than the probe fragment, or
1095
+ // malformed) must FAIL CLOSED: only a provably nullable extra
1096
+ // argument is a compatible addition.
1097
+ const servedArgType = typeRefString(servedArg.type);
1098
+ if (servedArgType === null || servedArgType.endsWith("!")) {
1099
+ failures.push(
1100
+ `${typeName}.${fieldName} adds an argument ${argName}: ${String(servedArgType)} that is required or unrenderable, which breaks existing callers`,
1101
+ );
1102
+ }
1103
+ }
1104
+ }
1105
+ } else if (expected.kind === "INPUT_OBJECT") {
1106
+ const servedFields = new Map(
1107
+ (served.inputFields ?? []).map((field) => [field.name, field]),
1108
+ );
1109
+ for (const [fieldName, expectedType] of Object.entries(
1110
+ expected.inputFields,
1111
+ )) {
1112
+ const servedField = servedFields.get(fieldName);
1113
+ const servedType = servedField ? typeRefString(servedField.type) : null;
1114
+ if (servedType !== expectedType) {
1115
+ failures.push(
1116
+ `input ${typeName}.${fieldName} is served as ${servedType}, the projection defines ${expectedType}`,
1117
+ );
1118
+ }
1119
+ }
1120
+ for (const [fieldName, servedField] of servedFields) {
1121
+ if (Object.hasOwn(expected.inputFields, fieldName)) continue;
1122
+ // Fail closed on an unrenderable ref, as for arguments above.
1123
+ const servedType = typeRefString(servedField.type);
1124
+ if (servedType === null || servedType.endsWith("!")) {
1125
+ failures.push(
1126
+ `input ${typeName} adds a member ${fieldName}: ${String(servedType)} that is required or unrenderable, which breaks existing callers`,
1127
+ );
1128
+ }
1129
+ }
1130
+ } else if (expected.kind === "ENUM") {
1131
+ // Closed enumeration: adding OR removing a value is MAJOR (SPEC.md 12).
1132
+ const servedValues = (served.enumValues ?? [])
1133
+ .map((value) => value.name)
1134
+ .sort();
1135
+ if (stableStringify(servedValues) !== stableStringify(expected.values)) {
1136
+ failures.push(
1137
+ `enum ${typeName} serves ${JSON.stringify(servedValues)}, the projection defines ${JSON.stringify(expected.values)}`,
1138
+ );
1139
+ }
1140
+ }
1141
+ }
1142
+ return failures;
1143
+ }
1144
+
1145
+ async function probeGraphqlExecutor(
1146
+ adapter,
1147
+ forbidden = [],
1148
+ probeRole = "server",
1149
+ ) {
1150
+ const failures = [];
1151
+ if (probeRole !== "server" && probeRole !== "verification") {
1152
+ return [
1153
+ "no credentialed role is available to probe the executor — pass the credentials this provider issues",
1154
+ ];
1155
+ }
1156
+ // Probe an operation authorized by a role the provider actually exposes;
1157
+ // otherwise a valid FORBIDDEN response looks like an executor failure.
1158
+ const target =
1159
+ probeRole === "server"
1160
+ ? {
1161
+ name: "SubscriptionStatus",
1162
+ field: "subscriptionStatus",
1163
+ inputType: "SubscriptionStatusInput",
1164
+ validInput: { userId: "executor-probe" },
1165
+ resultField: "active",
1166
+ }
1167
+ : {
1168
+ name: "VerifyPurchase",
1169
+ field: "verifyPurchase",
1170
+ inputType: "VerifyPurchaseInput",
1171
+ validInput: { store: "a_store_openiap_has_never_heard_of" },
1172
+ resultField: "isValid",
1173
+ };
1174
+ const send = (payload, credential = null) =>
1175
+ adapter.rawGraphql(payload, credential);
1176
+ const isRequestRejection = (result) =>
1177
+ isWellFormedRequestRejection(result.status, result.body);
1178
+ // SPEC.md 8 hygiene on the probes' own responses — the same helper the
1179
+ // vector envelope checks use, so a credential echoed only into a probe
1180
+ // rejection (a path no vector exercises) still fails.
1181
+ const hygiene = (result, label) =>
1182
+ messageHygieneFailures(
1183
+ Array.isArray(result.body?.errors)
1184
+ ? result.body.errors.map((error) => error?.message)
1185
+ : [],
1186
+ forbidden,
1187
+ label,
1188
+ );
1189
+
1190
+ // 1. A BROKEN query text paired with a real operationName and valid
1191
+ // variables and credential. A real executor parses the query and fails; a
1192
+ // dispatcher that ignores the query text and keys on operationName would run
1193
+ // the selected operation and return data. Returning data here is the tell.
1194
+ const broken = await send(
1195
+ {
1196
+ query: "this is not graphql at all {{{",
1197
+ operationName: target.name,
1198
+ variables: { input: target.validInput },
1199
+ },
1200
+ probeRole,
1201
+ );
1202
+ failures.push(...hygiene(broken, "probe: syntactically invalid query"));
1203
+ if (broken.body?.data !== undefined || !isRequestRejection(broken)) {
1204
+ failures.push(
1205
+ "a syntactically invalid query was executed (operationName-only dispatch?)",
1206
+ );
1207
+ }
1208
+
1209
+ // 2. A syntactically valid query that selects a field the result type does
1210
+ // not define, again with a real operationName. A real executor rejects it at
1211
+ // validation; a dispatcher returns data.
1212
+ const unknownField = await send(
1213
+ {
1214
+ query: `query ${target.name}($input: ${target.inputType}!) { ${target.field}(input: $input) { thisFieldDoesNotExist } }`,
1215
+ operationName: target.name,
1216
+ variables: { input: target.validInput },
1217
+ },
1218
+ probeRole,
1219
+ );
1220
+ failures.push(...hygiene(unknownField, "probe: undefined field"));
1221
+ if (
1222
+ unknownField.body?.data !== undefined ||
1223
+ !isRequestRejection(unknownField)
1224
+ ) {
1225
+ failures.push(
1226
+ "a query selecting an undefined field was executed (no validation?)",
1227
+ );
1228
+ }
1229
+
1230
+ // 3. An input variable of the wrong SHAPE (a scalar where the schema expects
1231
+ // an input object) must fail graphql-js input coercion.
1232
+ const badVariable = await send(
1233
+ {
1234
+ query: `query ${target.name}($input: ${target.inputType}!) { ${target.field}(input: $input) { ${target.resultField} } }`,
1235
+ operationName: target.name,
1236
+ variables: { input: "not-an-input-object" },
1237
+ },
1238
+ probeRole,
1239
+ );
1240
+ failures.push(...hygiene(badVariable, "probe: mistyped variable"));
1241
+ if (
1242
+ badVariable.body?.data?.[target.field] !== undefined ||
1243
+ !isRequestRejection(badVariable)
1244
+ ) {
1245
+ failures.push("a mistyped input variable was not rejected");
1246
+ }
1247
+ // SPEC.md 8: messages never contain submitted evidence. graphql-js coercion
1248
+ // messages echo the whole variable value, so a provider that passes them
1249
+ // through verbatim would echo a JWS or purchase token the same way.
1250
+ if (JSON.stringify(badVariable.body ?? {}).includes("not-an-input-object")) {
1251
+ failures.push(
1252
+ "a request error echoes the submitted input value back (SPEC.md 8 forbids evidence in error messages)",
1253
+ );
1254
+ }
1255
+
1256
+ // 4. Introspection must agree with the projection STRUCTURALLY (SPEC.md 7):
1257
+ // names alone would miss a retyped argument, flipped nullability, a dropped
1258
+ // input member, or a mutated closed enum. The generated signature is
1259
+ // compared as a subset — everything it names must be served identically;
1260
+ // MINOR additions are limited to new types, open-object fields, and nullable
1261
+ // arguments or input members (SPEC.md 12).
1262
+ const TYPE_REF =
1263
+ "kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name } } } }";
1264
+ // Use the same credential as probes 1-3 because production providers may
1265
+ // gate introspection behind authentication.
1266
+ const introspection = await send(
1267
+ {
1268
+ query: `query { __schema { queryType { name } mutationType { name } types { name kind fields(includeDeprecated: true) { name args { name type { ${TYPE_REF} } } type { ${TYPE_REF} } } inputFields { name type { ${TYPE_REF} } } enumValues(includeDeprecated: true) { name } } } }`,
1269
+ },
1270
+ probeRole,
1271
+ );
1272
+ // SPEC.md 7: "Introspection, WHERE ENABLED, must agree" — a provider that
1273
+ // disables introspection (a common production default) answers this with a
1274
+ // WELL-FORMED §7 request-level rejection and skips the agreement check;
1275
+ // probes 1-3 still certify the executor. Anything else — an HTTP 500, a
1276
+ // coded 400, a bodiless response — is a failure, not a disabled feature.
1277
+ const introspectionDisabled = isWellFormedRequestRejection(
1278
+ introspection.status,
1279
+ introspection.body,
1280
+ );
1281
+ failures.push(...hygiene(introspection, "probe: introspection"));
1282
+ if (introspectionDisabled) {
1283
+ // Nothing to compare.
1284
+ } else {
1285
+ failures.push(...compareIntrospection(introspection.body?.data?.__schema));
1286
+ }
1287
+ return failures;
1288
+ }
1289
+
1290
+ /** Reads the provider's capability outcome once for gating and certification. */
1291
+ async function readCapabilityOutcome(adapter) {
1292
+ try {
1293
+ const returned = await adapter.request({
1294
+ operation: "providerCapabilities",
1295
+ input: null,
1296
+ credential: null,
1297
+ });
1298
+ return returned &&
1299
+ typeof returned === "object" &&
1300
+ typeof returned.kind === "string"
1301
+ ? returned
1302
+ : {
1303
+ kind: "invalid",
1304
+ status: 0,
1305
+ detail: `adapter contract: request() returned ${JSON.stringify(returned) ?? String(returned)} instead of an outcome`,
1306
+ };
1307
+ } catch (error) {
1308
+ return {
1309
+ kind: "invalid",
1310
+ status: 0,
1311
+ detail: `adapter threw: ${error instanceof Error ? error.message : String(error)}`,
1312
+ };
1313
+ }
1314
+ }
1315
+
1316
+ const majorOf = (version) => String(version ?? "").split(".")[0];
1317
+
1318
+ /**
1319
+ * Checks the descriptor's declared versions agree with the generated manifest.
1320
+ * SPEC.md §3 and §12: the spec, every profile, and every binding version as
1321
+ * MAJOR.MINOR, and a caller pins on the MAJOR. A newer compatible minor — the
1322
+ * provider serves a superset the runner still understands — MUST certify, so
1323
+ * this compares majors and never exact strings. A different major is the trap
1324
+ * (a silently incompatible surface) and is what fails.
1325
+ */
1326
+ function checkVersionAgreement(capabilities, adapter) {
1327
+ const failures = [];
1328
+ if (!capabilities) return failures;
1329
+ if (
1330
+ majorOf(capabilities.specVersion) !==
1331
+ majorOf(httpBindingManifest.protocolVersion)
1332
+ ) {
1333
+ failures.push(
1334
+ `specVersion ${capabilities.specVersion} disagrees with protocol major of ${httpBindingManifest.protocolVersion}`,
1335
+ );
1336
+ }
1337
+ for (const [name, version] of Object.entries(capabilities.profiles ?? {})) {
1338
+ const declared = httpBindingManifest.profiles[name];
1339
+ if (declared !== undefined && majorOf(declared) !== majorOf(version)) {
1340
+ failures.push(
1341
+ `profile ${name} major ${majorOf(version)} disagrees with manifest ${declared}`,
1342
+ );
1343
+ }
1344
+ }
1345
+ const bindingVersion = capabilities.bindings?.[adapter.binding];
1346
+ const manifestBinding = httpBindingManifest.bindings[adapter.binding];
1347
+ if (
1348
+ bindingVersion !== undefined &&
1349
+ manifestBinding !== undefined &&
1350
+ majorOf(bindingVersion) !== majorOf(manifestBinding)
1351
+ ) {
1352
+ failures.push(
1353
+ `binding ${adapter.binding} major ${majorOf(bindingVersion)} disagrees with manifest ${manifestBinding}`,
1354
+ );
1355
+ }
1356
+ return failures;
1357
+ }
1358
+
1359
+ /**
1360
+ * Certifies a declared `events` profile against the §9 rules the vectors can
1361
+ * express, not just positive signing — a provider that ships only a signer
1362
+ * does not implement the profile. Each adapter method is exercised against
1363
+ * the published vectors:
1364
+ *
1365
+ * - `sign` §9.4.2 — reproduce every expected signature
1366
+ * - `verify` §9.4.2 — accept a valid delivery inside the clock-skew
1367
+ * tolerance in both directions, accept a rotated header
1368
+ * while holding either key alone, and reject every
1369
+ * rejection vector (tamper, wrong key, stale timestamp,
1370
+ * body-only signature, reused retry signature,
1371
+ * garbage-appended signature)
1372
+ * - `delivery` §9.4.1 — a POST application/json envelope with the four
1373
+ * headers, eventId and delivery-id stable across a retry
1374
+ * while timestamp and signature refresh
1375
+ * - `classifyResponse §9.4.3 — map every consumer status, plus timeout and
1376
+ * connection error, to delivered / retry / permanent-failure
1377
+ * - `entitled` §2.3 — the entitlement gate, on every lifecycle vector
1378
+ * - `emission` §9.1 — emit the right event list for a lifecycle change
1379
+ * - `coalesceAtBinding §2.4 — coalesce unbound gate deltas at first binding
1380
+ *
1381
+ * A missing method is a conformance failure, so a signing-only adapter fails.
1382
+ * SPEC.md 11.3 names the §9 rules that stay OUTSIDE this surface (§9.2
1383
+ * mapping, §9.3 document schema, §9.4.4 backoff, §9.4.5 destination safety).
1384
+ */
1385
+ async function checkEventsProfile(capabilities, eventsAdapter, lifecycle) {
1386
+ const failures = [];
1387
+ if (!capabilities?.profiles?.events) return failures;
1388
+ if (!eventsAdapter || typeof eventsAdapter.sign !== "function") {
1389
+ failures.push(
1390
+ "events profile is declared but no eventsAdapter.sign was supplied to verify signature and delivery vectors",
1391
+ );
1392
+ return failures;
1393
+ }
1394
+ for (const method of [
1395
+ "verify",
1396
+ "delivery",
1397
+ "classifyResponse",
1398
+ "entitled",
1399
+ "emission",
1400
+ "coalesceAtBinding",
1401
+ ]) {
1402
+ if (typeof eventsAdapter[method] !== "function") {
1403
+ failures.push(
1404
+ `events profile requires eventsAdapter.${method} (SPEC.md §9); a signing-only provider does not implement the events profile`,
1405
+ );
1406
+ }
1407
+ }
1408
+
1409
+ const headerNames = signatureVectors.headers ?? {};
1410
+
1411
+ // §9.4.2 produce: reproduce every expected signature.
1412
+ for (const vector of signatureVectors.cases ?? []) {
1413
+ // During rotation the emitter signs with the current and previous secrets
1414
+ // and joins them; the single-key signer is the adapter's job, composing
1415
+ // the header is the transport rule's, so the runner composes it here.
1416
+ const secrets = [vector.secret];
1417
+ if (vector.previousSecret) secrets.push(vector.previousSecret);
1418
+ const signed = (
1419
+ await Promise.all(
1420
+ secrets.map((secret) =>
1421
+ eventsAdapter.sign({
1422
+ secret,
1423
+ timestamp: vector.timestamp,
1424
+ body: vector.body,
1425
+ }),
1426
+ ),
1427
+ )
1428
+ ).join(",");
1429
+ if (signed !== vector.expected) {
1430
+ failures.push(
1431
+ `signature vector ${vector.name ?? vector.expected}: got ${signed}, expected ${vector.expected}`,
1432
+ );
1433
+ }
1434
+ }
1435
+
1436
+ // §9.4.2 consumer rules: accept a valid delivery and reject every rejection.
1437
+ if (typeof eventsAdapter.verify === "function") {
1438
+ const tolerance = signatureVectors.toleranceSeconds;
1439
+ for (const vector of signatureVectors.cases ?? []) {
1440
+ const secrets = [vector.secret];
1441
+ if (vector.previousSecret) secrets.push(vector.previousSecret);
1442
+ const header = vector.presentedHeader ?? vector.expected;
1443
+ // SPEC.md 9.4.2 rule 1 rejects only |now - timestamp| > tolerance, so
1444
+ // the boundary itself is INSIDE: exactly ±tolerance must be accepted
1445
+ // (a >= comparison must not certify) and ±(tolerance+1) must be
1446
+ // rejected (a > tolerance+1 comparison must not certify either).
1447
+ for (const [now, label] of [
1448
+ [vector.timestamp, "no skew"],
1449
+ [vector.timestamp + tolerance, `+${tolerance}s skew`],
1450
+ [vector.timestamp - tolerance, `-${tolerance}s skew`],
1451
+ ]) {
1452
+ const accepted = await eventsAdapter.verify({
1453
+ body: vector.body,
1454
+ timestamp: vector.timestamp,
1455
+ signature: header,
1456
+ secrets,
1457
+ now,
1458
+ });
1459
+ if (!accepted) {
1460
+ failures.push(
1461
+ `signature case ${vector.name} (${label}): |now - timestamp| <= ${tolerance} must be accepted`,
1462
+ );
1463
+ }
1464
+ }
1465
+ for (const [now, label] of [
1466
+ [vector.timestamp + tolerance + 1, `+${tolerance + 1}s skew`],
1467
+ [vector.timestamp - tolerance - 1, `-${tolerance + 1}s skew`],
1468
+ ]) {
1469
+ const accepted = await eventsAdapter.verify({
1470
+ body: vector.body,
1471
+ timestamp: vector.timestamp,
1472
+ signature: header,
1473
+ secrets,
1474
+ now,
1475
+ });
1476
+ if (accepted) {
1477
+ failures.push(
1478
+ `signature case ${vector.name} (${label}): |now - timestamp| > ${tolerance} must be rejected as stale`,
1479
+ );
1480
+ }
1481
+ }
1482
+ // §9.4.2 rule 2: a receiver holding EITHER key alone must accept the
1483
+ // rotated header — any presented signature matching any held secret is
1484
+ // enough. Checking only one side would let an all-must-match verifier
1485
+ // pass.
1486
+ if (vector.previousSecret) {
1487
+ for (const [held, label] of [
1488
+ [vector.previousSecret, "previous"],
1489
+ [vector.secret, "current"],
1490
+ ]) {
1491
+ const rotated = await eventsAdapter.verify({
1492
+ body: vector.body,
1493
+ timestamp: vector.timestamp,
1494
+ signature: header,
1495
+ secrets: [held],
1496
+ now: vector.timestamp,
1497
+ });
1498
+ if (!rotated) {
1499
+ failures.push(
1500
+ `rotation ${vector.name}: a receiver holding only the ${label} secret must accept a rotated header`,
1501
+ );
1502
+ }
1503
+ }
1504
+ }
1505
+ }
1506
+ for (const rejection of signatureVectors.rejections ?? []) {
1507
+ const accepted = await eventsAdapter.verify({
1508
+ body: rejection.body,
1509
+ timestamp: rejection.timestamp,
1510
+ signature: rejection.presentedSignature,
1511
+ secrets: [rejection.secret],
1512
+ now: rejection.receiverNow ?? rejection.timestamp,
1513
+ });
1514
+ if (accepted) {
1515
+ failures.push(
1516
+ `signature rejection ${rejection.name ?? "case"} was accepted but must be rejected`,
1517
+ );
1518
+ }
1519
+ }
1520
+ }
1521
+
1522
+ // §9.4.1 envelope: the four headers, and retry-chain stability.
1523
+ if (typeof eventsAdapter.delivery === "function") {
1524
+ const chains = new Map();
1525
+ for (const vector of signatureVectors.cases ?? []) {
1526
+ if (!vector.deliveryId) continue;
1527
+ const secrets = [vector.secret];
1528
+ if (vector.previousSecret) secrets.push(vector.previousSecret);
1529
+ const event = JSON.parse(vector.body);
1530
+ const composed = await eventsAdapter.delivery({
1531
+ event,
1532
+ body: vector.body,
1533
+ timestamp: vector.timestamp,
1534
+ secrets,
1535
+ deliveryId: vector.deliveryId,
1536
+ });
1537
+ // §9.4.1: the envelope is a POST with a JSON body — a GET or a
1538
+ // text/plain delivery is not the webhook contract, whatever it signs.
1539
+ if (composed?.method !== "POST") {
1540
+ failures.push(
1541
+ `delivery ${vector.name}: the envelope method must be POST, got ${String(composed?.method)}`,
1542
+ );
1543
+ }
1544
+ if (composed?.contentType !== "application/json") {
1545
+ failures.push(
1546
+ `delivery ${vector.name}: Content-Type must be application/json, got ${String(composed?.contentType)}`,
1547
+ );
1548
+ }
1549
+ const got = composed?.headers ?? {};
1550
+ // SPEC.md 9.4.1: Content-Encoding MUST be absent or identity —
1551
+ // transport compression makes "raw body bytes" ambiguous, so a gzip
1552
+ // delivery breaks signature verification by construction. EVERY header
1553
+ // whose name case-folds to content-encoding is checked (a second key
1554
+ // in a different casing must not slip past a first-match lookup), and
1555
+ // the value comparison is case-insensitive per RFC 9110 §8.4.1.
1556
+ for (const [name, value] of Object.entries(got)) {
1557
+ if (name.toLowerCase() !== "content-encoding") continue;
1558
+ if (String(value).trim().toLowerCase() !== "identity") {
1559
+ failures.push(
1560
+ `delivery ${vector.name}: Content-Encoding must be absent or identity, got ${String(value)}`,
1561
+ );
1562
+ }
1563
+ }
1564
+ const expectSig = vector.presentedHeader ?? vector.expected;
1565
+ const requiredHeaders = [
1566
+ [headerNames.signature, expectSig, "the signed header"],
1567
+ [
1568
+ headerNames.timestamp,
1569
+ String(vector.timestamp),
1570
+ "the signing timestamp in seconds",
1571
+ ],
1572
+ [headerNames.eventId, event.eventId, "body.eventId"],
1573
+ [headerNames.deliveryId, vector.deliveryId, "the attempt-chain id"],
1574
+ ];
1575
+ const normalizedHeaders = {};
1576
+ for (const [
1577
+ expectedName,
1578
+ expectedValue,
1579
+ description,
1580
+ ] of requiredHeaders) {
1581
+ const matches = Object.entries(got).filter(
1582
+ ([name]) => name.toLowerCase() === expectedName,
1583
+ );
1584
+ if (matches.length !== 1) {
1585
+ failures.push(
1586
+ `delivery ${vector.name}: ${expectedName} must occur exactly once, got ${matches.length}`,
1587
+ );
1588
+ continue;
1589
+ }
1590
+ normalizedHeaders[expectedName] = matches[0][1];
1591
+ if (String(matches[0][1]) !== String(expectedValue)) {
1592
+ failures.push(
1593
+ `delivery ${vector.name}: ${expectedName} must equal ${description}`,
1594
+ );
1595
+ }
1596
+ }
1597
+ const chain = chains.get(vector.deliveryId) ?? [];
1598
+ chain.push(normalizedHeaders);
1599
+ chains.set(vector.deliveryId, chain);
1600
+ }
1601
+ for (const [deliveryId, chain] of chains) {
1602
+ if (chain.length < 2) continue;
1603
+ const [first, next] = chain;
1604
+ if (first[headerNames.eventId] !== next[headerNames.eventId]) {
1605
+ failures.push(
1606
+ `retry chain ${deliveryId}: eventId changed across attempts`,
1607
+ );
1608
+ }
1609
+ if (
1610
+ String(first[headerNames.timestamp]) ===
1611
+ String(next[headerNames.timestamp])
1612
+ ) {
1613
+ failures.push(
1614
+ `retry chain ${deliveryId}: a retry must choose a fresh timestamp`,
1615
+ );
1616
+ }
1617
+ if (first[headerNames.signature] === next[headerNames.signature]) {
1618
+ failures.push(
1619
+ `retry chain ${deliveryId}: a retry must recompute the signature`,
1620
+ );
1621
+ }
1622
+ }
1623
+ }
1624
+
1625
+ // §9.4.3 response semantics, exhaustively: every status from 200 to 599 is
1626
+ // classified and compared to the table's rule — sampling would let a
1627
+ // classifier mis-map an unprobed status (a 304 marked retryable would
1628
+ // follow a redirect's cache path forever). Timeout and connection error
1629
+ // (no status at all) map to retry.
1630
+ if (typeof eventsAdapter.classifyResponse === "function") {
1631
+ const ruleFor = (status) => {
1632
+ if (status >= 200 && status < 300) return "delivered";
1633
+ if (status === 408 || status === 429 || status >= 500) return "retry";
1634
+ return "permanent-failure";
1635
+ };
1636
+ for (let status = 200; status <= 599; status += 1) {
1637
+ const action = await eventsAdapter.classifyResponse(status);
1638
+ const expected = ruleFor(status);
1639
+ if (action !== expected) {
1640
+ failures.push(
1641
+ `response ${status}: expected ${expected}, got ${action}`,
1642
+ );
1643
+ }
1644
+ }
1645
+ // The published sample table must itself agree with the rule — a drifted
1646
+ // vectors file is a spec bug, not a provider bug, but fail loudly.
1647
+ for (const testCase of signatureVectors.responseSemantics?.cases ?? []) {
1648
+ if (ruleFor(testCase.status) !== testCase.action) {
1649
+ failures.push(
1650
+ `responseSemantics vector ${testCase.status} disagrees with the SPEC.md 9.4.3 rule`,
1651
+ );
1652
+ }
1653
+ }
1654
+ const noResponse = signatureVectors.responseSemantics?.connectionError;
1655
+ if (noResponse) {
1656
+ for (const sentinel of ["connection-error", "timeout"]) {
1657
+ const action = await eventsAdapter.classifyResponse(sentinel);
1658
+ if (action !== noResponse) {
1659
+ failures.push(
1660
+ `response ${sentinel}: expected ${noResponse}, got ${action}`,
1661
+ );
1662
+ }
1663
+ }
1664
+ }
1665
+ }
1666
+
1667
+ // §2.3 entitlement gate: the predicate the emission rule flips on. Every
1668
+ // lifecycle vector — state, expiry boundary, missing expiry — must reproduce.
1669
+ if (typeof eventsAdapter.entitled === "function") {
1670
+ for (const testCase of lifecycle?.entitlement?.cases ?? []) {
1671
+ const got = await eventsAdapter.entitled({
1672
+ state: testCase.state,
1673
+ expiresAt: testCase.expiresAt,
1674
+ processedAt: testCase.processedAt,
1675
+ });
1676
+ if (got !== testCase.entitled) {
1677
+ failures.push(
1678
+ `entitlement ${testCase.name}: expected ${testCase.entitled}, got ${got}`,
1679
+ );
1680
+ }
1681
+ }
1682
+ }
1683
+
1684
+ // §9.1 emission and §2.4 binding coalescing. Everything the adapter emits
1685
+ // is also checked against the descriptor's declared eventTypes (§10): a
1686
+ // provider that emits a type it never declared is dishonest about its
1687
+ // surface, whichever direction the mismatch runs.
1688
+ const declaredEventTypes = Array.isArray(capabilities.eventTypes)
1689
+ ? new Set(capabilities.eventTypes)
1690
+ : null;
1691
+ if (declaredEventTypes === null) {
1692
+ // Never skip silently: this row must not read as "honesty verified"
1693
+ // when the declaration the check compares against is missing entirely.
1694
+ failures.push(
1695
+ "events profile is declared but the descriptor carries no eventTypes array to check emissions against",
1696
+ );
1697
+ }
1698
+ const checkDeclared = (emitted, label) => {
1699
+ if (!declaredEventTypes || !Array.isArray(emitted)) return;
1700
+ for (const type of emitted) {
1701
+ if (!declaredEventTypes.has(type)) {
1702
+ failures.push(
1703
+ `${label} emits ${JSON.stringify(type)}, which the capability descriptor's eventTypes does not declare`,
1704
+ );
1705
+ }
1706
+ }
1707
+ };
1708
+ if (typeof eventsAdapter.emission === "function") {
1709
+ for (const testCase of lifecycle?.emission?.cases ?? []) {
1710
+ const emitted = await eventsAdapter.emission({
1711
+ lifecycleEvent: testCase.lifecycleEvent,
1712
+ entitledBefore: testCase.entitledBefore,
1713
+ entitledAfter: testCase.entitledAfter,
1714
+ });
1715
+ if (stableStringify(emitted) !== stableStringify(testCase.emit)) {
1716
+ failures.push(
1717
+ `emission ${testCase.name}: expected ${JSON.stringify(testCase.emit)}, got ${JSON.stringify(emitted)}`,
1718
+ );
1719
+ }
1720
+ checkDeclared(emitted, `emission ${testCase.name}`);
1721
+ }
1722
+ }
1723
+ if (typeof eventsAdapter.coalesceAtBinding === "function") {
1724
+ for (const testCase of lifecycle?.binding?.cases ?? []) {
1725
+ const emitted = await eventsAdapter.coalesceAtBinding({
1726
+ unboundGateChanges: testCase.unboundGateChanges,
1727
+ entitledAtBinding: testCase.entitledAtBinding,
1728
+ });
1729
+ checkDeclared(emitted, `binding ${testCase.name}`);
1730
+ if (stableStringify(emitted) !== stableStringify(testCase.emit)) {
1731
+ failures.push(
1732
+ `binding ${testCase.name}: expected ${JSON.stringify(testCase.emit)}, got ${JSON.stringify(emitted)}`,
1733
+ );
1734
+ }
1735
+ }
1736
+ }
1737
+
1738
+ return failures;
1739
+ }
1740
+
1741
+ /**
1742
+ * Runs every operation vector against each adapter, then checks that the
1743
+ * normalized outcome of every deterministic case agrees across bindings.
1744
+ * Returns `{ ok, results, parityFailures }`; nothing is thrown for a
1745
+ * conformance failure, so a caller can report all of them at once.
1746
+ */
1747
+ export async function runConformance({
1748
+ adapters,
1749
+ Ajv,
1750
+ eventsAdapter,
1751
+ credentials,
1752
+ }) {
1753
+ // The §8 credential-echo scan needs the AUTHORITATIVE credential values
1754
+ // from the caller — adapter-declared secrets alone could be emptied by a
1755
+ // non-conforming adapter, silently disabling the scan. Every role THIS RUN
1756
+ // exercises must be present (checked at first use below): passing only one
1757
+ // of two configured roles would leave the other credential unscanned,
1758
+ // while a legal partial-profile provider that never uses the server role
1759
+ // is not asked for a credential it does not have.
1760
+ const requireRoleCredential = (role) => {
1761
+ if (role === null || role === "invalid") return;
1762
+ const value = credentials?.[role];
1763
+ if (typeof value !== "string" || value.length === 0) {
1764
+ throw new Error(
1765
+ `runConformance needs credentials.${role}: this run exercises the ${role} role, and the SPEC.md 8 credential-echo scan cannot cover a credential it was not given`,
1766
+ );
1767
+ }
1768
+ };
1769
+ if (!Array.isArray(adapters) || adapters.length === 0) {
1770
+ throw new Error("runConformance needs at least one adapter");
1771
+ }
1772
+ const validate = buildValidator(Ajv);
1773
+ const results = [];
1774
+ const outcomesByCase = new Map();
1775
+ let cachedEventsFailures = null;
1776
+
1777
+ const storeVectorIsEligible = (vector, capabilities, declaredStores) => {
1778
+ if (!vector.requiresStore || declaredStores === null) return true;
1779
+ if (!declaredStores.has(vector.requiresStore)) return false;
1780
+ return (
1781
+ !vector.requiresCapability ||
1782
+ capabilities?.stores?.[vector.requiresStore]?.[vector.requiresCapability]
1783
+ ?.implementation === true
1784
+ );
1785
+ };
1786
+
1787
+ for (const adapter of adapters) {
1788
+ // SPEC.md 3 and 11.1 scope conformance to the profiles a provider
1789
+ // declares, so a partial-but-legal provider certifies the profiles it
1790
+ // serves instead of failing on operations it never claimed. `core`
1791
+ // (providerCapabilities) always runs.
1792
+ const capabilityOutcome = await readCapabilityOutcome(adapter);
1793
+ const capabilities =
1794
+ capabilityOutcome.kind === "result" &&
1795
+ capabilityOutcome.data &&
1796
+ typeof capabilityOutcome.data === "object"
1797
+ ? capabilityOutcome.data
1798
+ : null;
1799
+ const declaredProfiles =
1800
+ capabilities?.profiles && typeof capabilities.profiles === "object"
1801
+ ? new Set(Object.keys(capabilities.profiles))
1802
+ : null;
1803
+ const declaredStores =
1804
+ capabilities?.stores && typeof capabilities.stores === "object"
1805
+ ? new Set(Object.keys(capabilities.stores))
1806
+ : null;
1807
+
1808
+ // Adapter contract: the credential values feed the SPEC.md 8 message
1809
+ // scan. An adapter without them would skip credential-echo detection
1810
+ // silently, so their absence is itself a failure.
1811
+ if (!Array.isArray(adapter.secrets)) {
1812
+ results.push({
1813
+ id: "adapter.contract",
1814
+ binding: adapter.binding,
1815
+ ok: false,
1816
+ failures: [
1817
+ "the adapter exposes no secrets: string[] (the configured credential values) — credential echo in error messages cannot be detected without them",
1818
+ ],
1819
+ });
1820
+ }
1821
+
1822
+ const versionFailures = checkVersionAgreement(capabilities, adapter);
1823
+ results.push({
1824
+ id: "capabilities.version-agreement",
1825
+ binding: adapter.binding,
1826
+ ok: versionFailures.length === 0,
1827
+ failures: versionFailures,
1828
+ });
1829
+ for (const [profile, operation] of [
1830
+ ["verification", "verifyPurchase"],
1831
+ ["accountLifecycle", "bindPurchase"],
1832
+ ]) {
1833
+ if (!declaredProfiles?.has(profile) || declaredStores === null) continue;
1834
+ const hasEligibleStoreVector = operationVectors.cases.some(
1835
+ (vector) =>
1836
+ vector.operation === operation &&
1837
+ vector.requiresStore &&
1838
+ storeVectorIsEligible(vector, capabilities, declaredStores),
1839
+ );
1840
+ if (!hasEligibleStoreVector) {
1841
+ results.push({
1842
+ id: `${profile}.store-coverage`,
1843
+ binding: adapter.binding,
1844
+ ok: false,
1845
+ failures: [
1846
+ `the ${profile} profile declares no store capability this ${operationVectors.protocolVersion} runner can exercise`,
1847
+ ],
1848
+ });
1849
+ }
1850
+ }
1851
+ // The events obligations are transport-independent (signing, delivery,
1852
+ // emission — no binding involved), so the vectors run ONCE and the
1853
+ // verdict is reused for each binding's report row rather than re-driving
1854
+ // 400+ classifier calls per adapter.
1855
+ let eventsFailures;
1856
+ if (cachedEventsFailures !== null) {
1857
+ eventsFailures = cachedEventsFailures;
1858
+ } else {
1859
+ try {
1860
+ eventsFailures = await checkEventsProfile(
1861
+ capabilities,
1862
+ eventsAdapter,
1863
+ lifecycleVectors,
1864
+ );
1865
+ } catch (error) {
1866
+ eventsFailures = [
1867
+ `events verification threw: ${error instanceof Error ? error.message : String(error)}`,
1868
+ ];
1869
+ }
1870
+ if (capabilities?.profiles?.events) {
1871
+ cachedEventsFailures = eventsFailures;
1872
+ }
1873
+ }
1874
+ if (capabilities?.profiles?.events) {
1875
+ results.push({
1876
+ id: "events.profile-verification",
1877
+ binding: adapter.binding,
1878
+ ok: eventsFailures.length === 0,
1879
+ failures: eventsFailures,
1880
+ });
1881
+ }
1882
+
1883
+ // The GraphQL binding must be a real executor, not an operationName
1884
+ // dispatcher: probe it with documents the canonical queries never send.
1885
+ // A GraphQL adapter without rawGraphql cannot be probed, and skipping the
1886
+ // probe silently would grant "GraphQL-conformant" with zero executor
1887
+ // evidence — so the missing capability is itself a failure.
1888
+ if (adapter.binding === "graphql") {
1889
+ let probeFailures;
1890
+ if (typeof adapter.rawGraphql !== "function") {
1891
+ probeFailures = [
1892
+ "the adapter exposes no rawGraphql(payload, credential) method, so the executor probe cannot run — use createGraphqlAdapter or implement rawGraphql",
1893
+ ];
1894
+ } else {
1895
+ try {
1896
+ probeFailures = await probeGraphqlExecutor(
1897
+ adapter,
1898
+ forbiddenTokens({ input: undefined, adapter, credentials }),
1899
+ // A legal partial-profile provider may hold no server
1900
+ // credential; the probes only need SOME accepted bearer.
1901
+ typeof credentials?.server === "string" && credentials.server
1902
+ ? "server"
1903
+ : typeof credentials?.verification === "string" &&
1904
+ credentials.verification
1905
+ ? "verification"
1906
+ : null,
1907
+ );
1908
+ } catch (error) {
1909
+ probeFailures = [
1910
+ `executor probe threw: ${error instanceof Error ? error.message : String(error)}`,
1911
+ ];
1912
+ }
1913
+ }
1914
+ results.push({
1915
+ id: "graphql.executor-probe",
1916
+ binding: adapter.binding,
1917
+ ok: probeFailures.length === 0,
1918
+ failures: probeFailures,
1919
+ });
1920
+ }
1921
+
1922
+ let capabilitySnapshotPending = true;
1923
+ for (const vector of operationVectors.cases) {
1924
+ if (vector.bindings && !vector.bindings.includes(adapter.binding)) {
1925
+ continue;
1926
+ }
1927
+ const profile = operationsByName.get(vector.operation)?.profile;
1928
+ if (
1929
+ profile !== undefined &&
1930
+ profile !== "core" &&
1931
+ declaredProfiles !== null &&
1932
+ !declaredProfiles.has(profile)
1933
+ ) {
1934
+ continue;
1935
+ }
1936
+ if (!storeVectorIsEligible(vector, capabilities, declaredStores)) {
1937
+ continue;
1938
+ }
1939
+ const attempts = [];
1940
+ for (let attempt = 0; attempt < (vector.repeat ?? 1); attempt += 1) {
1941
+ requireRoleCredential(vector.credential);
1942
+ // One adapter throw (a dropped connection, a missing credential) must
1943
+ // become that case's failure, never abort the run and discard every
1944
+ // other collected result — and a malformed RETURN (null, a non-object,
1945
+ // an outcome without kind) is the same class of fault, not a crash.
1946
+ try {
1947
+ let returned;
1948
+ if (
1949
+ vector.operation === "providerCapabilities" &&
1950
+ capabilitySnapshotPending
1951
+ ) {
1952
+ returned = capabilityOutcome;
1953
+ capabilitySnapshotPending = false;
1954
+ } else {
1955
+ returned = await adapter.request({
1956
+ operation: vector.operation,
1957
+ input: vector.input,
1958
+ credential: vector.credential,
1959
+ });
1960
+ }
1961
+ attempts.push(
1962
+ returned &&
1963
+ typeof returned === "object" &&
1964
+ typeof returned.kind === "string"
1965
+ ? returned
1966
+ : {
1967
+ kind: "invalid",
1968
+ status: 0,
1969
+ detail: `adapter contract: request() returned ${JSON.stringify(returned) ?? String(returned)} instead of an outcome`,
1970
+ },
1971
+ );
1972
+ } catch (error) {
1973
+ attempts.push({
1974
+ kind: "invalid",
1975
+ status: 0,
1976
+ detail: `adapter threw: ${error instanceof Error ? error.message : String(error)}`,
1977
+ });
1978
+ }
1979
+ }
1980
+ const outcome = attempts[0];
1981
+ const failures = evaluateExpectation({
1982
+ outcome,
1983
+ expect: vector.expect,
1984
+ operationName: vector.operation,
1985
+ adapter,
1986
+ validate,
1987
+ input: vector.input,
1988
+ credentials,
1989
+ });
1990
+ if (
1991
+ vector.operation === "providerCapabilities" &&
1992
+ outcome.kind === "result" &&
1993
+ capabilities !== null &&
1994
+ stableStringify(outcome.data) !== stableStringify(capabilities)
1995
+ ) {
1996
+ failures.push(
1997
+ "providerCapabilities changed during the conformance run; the descriptor used for profile/store gating must be the descriptor being certified",
1998
+ );
1999
+ }
2000
+ // A real executor cannot answer a field the canonical document never
2001
+ // selected — an unrequested member on the GraphQL binding is fabricated
2002
+ // and must fail HERE, before projection could erase it from parity.
2003
+ // Judged on the PRE-normalization shape: a fabricated `member: null`
2004
+ // vanishes in normalizeResultData, so the normalized data cannot show it.
2005
+ if (adapter.binding === "graphql" && outcome.kind === "result") {
2006
+ // Value check, not presence: `rawData: undefined` must fail the
2007
+ // contract just like a missing member would.
2008
+ if (outcome.rawData === undefined) {
2009
+ failures.push(
2010
+ "adapter contract: a GraphQL result outcome must carry rawData (the pre-normalization shape) — the unrequested-member check cannot run without it",
2011
+ );
2012
+ }
2013
+ const tree = graphqlOperations.operations[vector.operation]?.selection;
2014
+ if (tree && typeof tree === "object") {
2015
+ for (const member of extraMembers(
2016
+ outcome.rawData ?? outcome.data,
2017
+ tree,
2018
+ )) {
2019
+ failures.push(
2020
+ `GraphQL returned ${member}, which the canonical document never requested`,
2021
+ );
2022
+ }
2023
+ }
2024
+ }
2025
+ try {
2026
+ failures.push(
2027
+ ...evaluateResultChecks({
2028
+ outcome,
2029
+ expect: vector.expect,
2030
+ adapter,
2031
+ validate,
2032
+ input: vector.input,
2033
+ }),
2034
+ );
2035
+ } catch (error) {
2036
+ // A malformed provider response must surface as a conformance
2037
+ // failure, never crash the whole run.
2038
+ failures.push(
2039
+ `result check threw: ${error instanceof Error ? error.message : String(error)}`,
2040
+ );
2041
+ }
2042
+ if (attempts.length > 1) {
2043
+ const ignore = vector.expect.ignoreMembers;
2044
+ const normalized = attempts.map((attempt) =>
2045
+ stableStringify(
2046
+ attempt.kind === "result"
2047
+ ? withoutMembers(attempt.data, ignore)
2048
+ : attempt,
2049
+ ),
2050
+ );
2051
+ if (new Set(normalized).size !== 1) {
2052
+ failures.push("repeated invocation was not idempotent");
2053
+ }
2054
+ }
2055
+ results.push({
2056
+ id: vector.id,
2057
+ binding: adapter.binding,
2058
+ ok: failures.length === 0,
2059
+ failures,
2060
+ });
2061
+ if (!vector.bindings) {
2062
+ const perBinding = outcomesByCase.get(vector.id) ?? new Map();
2063
+ perBinding.set(adapter.binding, {
2064
+ outcome,
2065
+ ignore: vector.expect.ignoreMembers,
2066
+ operation: vector.operation,
2067
+ });
2068
+ outcomesByCase.set(vector.id, perBinding);
2069
+ }
2070
+ }
2071
+ }
2072
+
2073
+ const parityFailures = [];
2074
+ for (const [caseId, perBinding] of outcomesByCase) {
2075
+ if (perBinding.size < 2) continue;
2076
+ // Parity is judged on the GENERATED canonical selection tree — this
2077
+ // protocol version's contract shape. A 1.x provider may add optional
2078
+ // members on an open result object (SPEC.md 12, MINOR); REST returns
2079
+ // them and the frozen GraphQL selection cannot, so members outside the
2080
+ // tree are excluded from the comparison. The shape must come from the
2081
+ // generated artifact, never from a live response: projecting onto the
2082
+ // GraphQL answer would erase a ONE-SIDED drop (GraphQL missing a
2083
+ // contract member REST still serves) from the comparison. Omitting an
2084
+ // optional member from BOTH bindings consistently is legal omission
2085
+ // (§4), not a parity concern. Vector-declared ignoreMembers (an erasure
2086
+ // job's progressing status) still trump the tree by design.
2087
+ const operationName = [...perBinding.values()][0]?.operation;
2088
+ const shape = graphqlOperations.operations[operationName]?.selection;
2089
+ const normalized = new Map(
2090
+ [...perBinding].map(([binding, { outcome, ignore }]) => [
2091
+ binding,
2092
+ stableStringify(
2093
+ outcome.kind === "result"
2094
+ ? {
2095
+ kind: "result",
2096
+ // Projection applies to the NON-GraphQL bindings only: the
2097
+ // GraphQL result was already checked raw against the tree
2098
+ // (unrequested members fail the case itself), so projecting
2099
+ // it here would only mask that check.
2100
+ data: withoutMembers(
2101
+ shape !== undefined &&
2102
+ typeof shape === "object" &&
2103
+ binding !== "graphql"
2104
+ ? projectOnto(outcome.data, shape)
2105
+ : outcome.data,
2106
+ ignore,
2107
+ ),
2108
+ }
2109
+ : { kind: outcome.kind, code: outcome.code },
2110
+ ),
2111
+ ]),
2112
+ );
2113
+ if (new Set(normalized.values()).size !== 1) {
2114
+ parityFailures.push({
2115
+ id: caseId,
2116
+ outcomes: Object.fromEntries(normalized),
2117
+ });
2118
+ }
2119
+ }
2120
+
2121
+ return {
2122
+ ok: results.every((result) => result.ok) && parityFailures.length === 0,
2123
+ results,
2124
+ parityFailures,
2125
+ };
2126
+ }