@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.
- package/CONVENTION.md +168 -0
- package/DESIGN.md +1056 -0
- package/README.md +227 -5
- package/SPEC.md +1471 -0
- package/conformance/index.d.ts +303 -0
- package/conformance/index.mjs +2126 -0
- package/conformance/mock-provider.mjs +491 -0
- package/examples/entitlement-granted-no-subscription.json +12 -0
- package/examples/entitlement-revoked.json +21 -0
- package/examples/provider-capabilities.json +209 -0
- package/examples/store-event-mapping.json +287 -0
- package/examples/subscription-canceled.json +22 -0
- package/examples/subscription-product-changed.json +30 -0
- package/examples/subscription-renewed.json +29 -0
- package/examples/verify-purchase-request.json +6 -0
- package/examples/verify-purchase-result.json +7 -0
- package/generated/bindings/graphql-operations.json +87 -0
- package/generated/bindings/http-binding.json +143 -0
- package/generated/bindings/introspection-signature.json +320 -0
- package/generated/bindings/operations-sdl.json +4 -0
- package/generated/bindings/operations.graphql +366 -0
- package/generated/commerce-protocol.graphql +1219 -0
- package/generated/openapi/commerce-protocol.openapi.json +1413 -0
- package/generated/schemas/commerce-event.schema.json +499 -0
- package/generated/schemas/commerce-protocol.bundle.schema.json +1576 -0
- package/generated/schemas/operations.schema.json +578 -0
- package/generated/schemas/primitives.schema.json +101 -0
- package/generated/schemas/provider-capabilities.schema.json +205 -0
- package/generated/schemas/store-event-mapping.schema.json +211 -0
- package/generated/vectors/lifecycle.json +908 -0
- package/generated/vectors/operations.json +1122 -0
- package/package.json +62 -12
- package/schema/01-primitives.graphql +102 -0
- package/schema/02-commerce-event.graphql +195 -0
- package/schema/03-provider-capabilities.graphql +139 -0
- package/schema/04-store-event-mapping.graphql +98 -0
- package/schema/05-operations.graphql +461 -0
- package/schema/06-compiler-vocabulary.graphql +139 -0
- package/schema/07-protocol-metadata.graphql +76 -0
- package/src/index.d.ts +63 -0
- package/src/index.mjs +121 -0
- 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
|
+
}
|