@fora-protocol/sdk 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +37 -0
  3. package/gen/ts/vocab/functiontokens.js +78 -0
  4. package/gen/ts/vocab/functiontokens.ts +84 -0
  5. package/gen/ts/vocab/geographytokens.js +30 -0
  6. package/gen/ts/vocab/geographytokens.ts +37 -0
  7. package/gen/ts/vocab/pricingunits.js +56 -0
  8. package/gen/ts/vocab/pricingunits.ts +63 -0
  9. package/gen/ts/vocab/quotametrics.js +40 -0
  10. package/gen/ts/vocab/quotametrics.ts +47 -0
  11. package/gen/ts/vocab/usertypes.js +40 -0
  12. package/gen/ts/vocab/usertypes.ts +46 -0
  13. package/gen/ts/wire/base.js +245 -0
  14. package/gen/ts/wire/base.ts +255 -0
  15. package/gen/ts/wire/names.js +41 -0
  16. package/gen/ts/wire/names.ts +42 -0
  17. package/gen/ts/wire/schemas.js +111 -0
  18. package/gen/ts/wire/schemas.ts +216 -0
  19. package/package.json +164 -0
  20. package/sdk/ts/client/content.d.ts +74 -0
  21. package/sdk/ts/client/content.js +466 -0
  22. package/sdk/ts/client/errors.d.ts +79 -0
  23. package/sdk/ts/client/errors.js +148 -0
  24. package/sdk/ts/client/index.d.ts +241 -0
  25. package/sdk/ts/client/index.js +740 -0
  26. package/sdk/ts/client/route.d.ts +41 -0
  27. package/sdk/ts/client/route.js +107 -0
  28. package/sdk/ts/client/send.d.ts +64 -0
  29. package/sdk/ts/client/send.js +142 -0
  30. package/sdk/ts/client/transport.d.ts +187 -0
  31. package/sdk/ts/client/transport.js +381 -0
  32. package/sdk/ts/core/multisig-parse.d.ts +47 -0
  33. package/sdk/ts/core/multisig-parse.js +234 -0
  34. package/sdk/ts/core/sign-request.d.ts +83 -0
  35. package/sdk/ts/core/sign-request.js +146 -0
  36. package/sdk/ts/core/sign.d.ts +31 -0
  37. package/sdk/ts/core/sign.js +82 -0
  38. package/sdk/ts/core/signing-transport.d.ts +75 -0
  39. package/sdk/ts/core/signing-transport.js +172 -0
  40. package/sdk/ts/core/verifier.d.ts +177 -0
  41. package/sdk/ts/core/verifier.js +269 -0
  42. package/sdk/ts/core/verify-multisig-request.d.ts +72 -0
  43. package/sdk/ts/core/verify-multisig-request.js +112 -0
  44. package/sdk/ts/core/verify-request.d.ts +181 -0
  45. package/sdk/ts/core/verify-request.js +284 -0
  46. package/sdk/ts/core/window.d.ts +22 -0
  47. package/sdk/ts/core/window.js +43 -0
  48. package/sdk/ts/core/wire-canon.d.ts +20 -0
  49. package/sdk/ts/core/wire-canon.js +174 -0
  50. package/sdk/ts/hono/middleware.d.ts +38 -0
  51. package/sdk/ts/hono/middleware.js +64 -0
  52. package/sdk/ts/resolvers/errors.d.ts +130 -0
  53. package/sdk/ts/resolvers/errors.js +150 -0
  54. package/sdk/ts/resolvers/http.d.ts +98 -0
  55. package/sdk/ts/resolvers/http.js +274 -0
  56. package/sdk/ts/resolvers/index.d.ts +9 -0
  57. package/sdk/ts/resolvers/index.js +15 -0
  58. package/sdk/ts/resolvers/jwks.d.ts +2 -0
  59. package/sdk/ts/resolvers/jwks.js +43 -0
  60. package/sdk/ts/resolvers/offer-key-cache.d.ts +68 -0
  61. package/sdk/ts/resolvers/offer-key-cache.js +152 -0
  62. package/sdk/ts/resolvers/registration-requirements.d.ts +47 -0
  63. package/sdk/ts/resolvers/registration-requirements.js +220 -0
  64. package/sdk/ts/resolvers/ssrf.d.ts +28 -0
  65. package/sdk/ts/resolvers/ssrf.js +235 -0
  66. package/sdk/ts/resolvers/static.d.ts +7 -0
  67. package/sdk/ts/resolvers/static.js +16 -0
  68. package/sdk/ts/resolvers/wba.d.ts +134 -0
  69. package/sdk/ts/resolvers/wba.js +506 -0
  70. package/sdk/ts/resolvers/wellknown.d.ts +28 -0
  71. package/sdk/ts/resolvers/wellknown.js +169 -0
  72. package/sdk/ts/src/acceptance.d.ts +46 -0
  73. package/sdk/ts/src/acceptance.js +162 -0
  74. package/sdk/ts/src/base64url.d.ts +19 -0
  75. package/sdk/ts/src/base64url.js +55 -0
  76. package/sdk/ts/src/crossfield.d.ts +28 -0
  77. package/sdk/ts/src/crossfield.js +215 -0
  78. package/sdk/ts/src/endpoint-rule.d.ts +30 -0
  79. package/sdk/ts/src/endpoint-rule.js +63 -0
  80. package/sdk/ts/src/errordetail.d.ts +115 -0
  81. package/sdk/ts/src/errordetail.js +256 -0
  82. package/sdk/ts/src/hashurl.d.ts +5 -0
  83. package/sdk/ts/src/hashurl.js +15 -0
  84. package/sdk/ts/src/host-ref.d.ts +70 -0
  85. package/sdk/ts/src/host-ref.js +320 -0
  86. package/sdk/ts/src/hosts.d.ts +138 -0
  87. package/sdk/ts/src/hosts.js +214 -0
  88. package/sdk/ts/src/idempotency.d.ts +11 -0
  89. package/sdk/ts/src/idempotency.js +27 -0
  90. package/sdk/ts/src/jsondepth.d.ts +27 -0
  91. package/sdk/ts/src/jsondepth.js +77 -0
  92. package/sdk/ts/src/licenseterm.d.ts +113 -0
  93. package/sdk/ts/src/licenseterm.js +433 -0
  94. package/sdk/ts/src/money.d.ts +21 -0
  95. package/sdk/ts/src/money.js +60 -0
  96. package/sdk/ts/src/offer-sign.d.ts +11 -0
  97. package/sdk/ts/src/offer-sign.js +31 -0
  98. package/sdk/ts/src/opaque-url.d.ts +3 -0
  99. package/sdk/ts/src/opaque-url.js +20 -0
  100. package/sdk/ts/src/pop.d.ts +51 -0
  101. package/sdk/ts/src/pop.js +166 -0
  102. package/sdk/ts/src/regschema.d.ts +229 -0
  103. package/sdk/ts/src/regschema.js +1315 -0
  104. package/sdk/ts/src/scopes.d.ts +20 -0
  105. package/sdk/ts/src/scopes.js +45 -0
  106. package/sdk/ts/src/signurl.d.ts +23 -0
  107. package/sdk/ts/src/signurl.js +107 -0
  108. package/sdk/ts/src/thumbprint.d.ts +14 -0
  109. package/sdk/ts/src/thumbprint.js +24 -0
  110. package/sdk/ts/src/verify.d.ts +49 -0
  111. package/sdk/ts/src/verify.js +109 -0
  112. package/sdk/ts/src/wire-names.d.ts +1 -0
  113. package/sdk/ts/src/wire-names.js +16 -0
  114. package/sdk/ts/src/wire.d.ts +68 -0
  115. package/sdk/ts/src/wire.js +114 -0
@@ -0,0 +1,1315 @@
1
+ // Safe validation of a published registration schema — TS port of the sdk/go
2
+ // oracle (helpers/regschema.go).
3
+ //
4
+ // An Exchange MAY publish AccountRegistration.data_schema in its fora.json: a JSON
5
+ // Schema describing the RegisterRequest.registration_data it expects. Two parties
6
+ // read that schema and MUST agree — the Exchange enforcing it on the way in, and a
7
+ // client pre-checking a payload before it signs and sends one. A payload that
8
+ // passes one and fails the other is the failure this module exists to remove, so
9
+ // the rules live in the SDK once rather than in each consumer's choice of library.
10
+ //
11
+ // The client's copy is the harder half: it arrives out of a THIRD PARTY's
12
+ // manifest, which makes a schema an attacker-influenced input reached before any
13
+ // signature is checked. Hence: no reference is resolved outside the document, the
14
+ // dialect is pinned to draft 2020-12, and size, depth and the `pattern` alphabet
15
+ // are bounded. The pattern rule is not only about backtracking — see
16
+ // isSafeSchemaPattern.
17
+ //
18
+ // Pure: bytes in, verdict out, no IO and no state. Byte-parity-guarded against the
19
+ // Go oracle by the shared vectors at
20
+ // sdk/go/helpers/testdata/registration-schema-vectors.json.
21
+ import { Ajv2020 } from "ajv/dist/2020.js";
22
+ import canonicalize from "canonicalize";
23
+ import { rawNestingDepth } from "./jsondepth.js";
24
+ /**
25
+ * maxRegistrationSchemaBytes is the published schema's size cap, measured as the
26
+ * UTF-8 bytes of the data_schema member AS SERVED in fora.json — which is why the
27
+ * compile face takes raw bytes rather than a decoded document. A re-encoding is a
28
+ * different length than what the origin sent, and the cap is defined over what the
29
+ * origin sent.
30
+ */
31
+ export const maxRegistrationSchemaBytes = 16384;
32
+ /**
33
+ * maxRegistrationSchemaDepth bounds how deeply the schema document may nest. It
34
+ * counts JSON containers, so a bare `{}` is depth 1. Deep allOf/$ref chains are
35
+ * the cheapest way to make a compile expensive, and a real registration schema is
36
+ * three to five levels deep.
37
+ */
38
+ export const maxRegistrationSchemaDepth = 32;
39
+ /**
40
+ * maxRegistrationFieldErrors is the number of member failures a refusal may carry
41
+ * — the wire's own bound (RegistrationFailure.field_errors declares
42
+ * repeated.max_items = 64), restated so the validator never builds a list the
43
+ * contract would reject.
44
+ */
45
+ export const maxRegistrationFieldErrors = 64;
46
+ /** The wire bounds on RegistrationFieldError.path and .error. */
47
+ export const maxRegistrationFieldErrorPathLen = 255;
48
+ export const maxRegistrationFieldErrorTextLen = 255;
49
+ /**
50
+ * registrationSchemaDialect is the only $schema value a published data_schema may
51
+ * name. A document that names none is read as this dialect; one that names another
52
+ * is refused rather than validated under semantics its author did not intend.
53
+ */
54
+ export const registrationSchemaDialect = "https://json-schema.org/draft/2020-12/schema";
55
+ /**
56
+ * maxRegistrationSchemaEvaluations bounds the WORK of checking a payload, which the
57
+ * size and depth caps do not: `anyOf` branches multiply along a reference chain, so a
58
+ * schema can be small and shallow and still cost an unbounded amount to evaluate.
59
+ * Cost is linear in this count, so the bound is really a time bound expressed as a
60
+ * number a static walk can compute and a shared corpus can pin, which a stopwatch
61
+ * cannot.
62
+ */
63
+ export const maxRegistrationSchemaEvaluations = 10000;
64
+ /**
65
+ * maxRegistrationSchemaRefHops bounds how long a `$ref` chain may be, measured as the
66
+ * longest path of reference hops rather than as the number of references a document
67
+ * contains.
68
+ *
69
+ * A SEPARATE axis from maxRegistrationSchemaDepth, and the shape that forced it shows
70
+ * why: a chain of five hundred definitions, each referring to the next, is three JSON
71
+ * containers deep however long it is, so the depth cap never sees it. The evaluation cap
72
+ * does not see it either — a flat chain costs one evaluation per link.
73
+ *
74
+ * What it bounds is the RECURSION a validator does while resolving that chain. The cost
75
+ * walk here does not care, but the libraries the three SDKs hand an accepted schema to
76
+ * do, and one of them exhausted its interpreter stack at 495 links — raising out of a
77
+ * face documented as returning a verdict, on a document every SDK had just called valid.
78
+ */
79
+ export const maxRegistrationSchemaRefHops = 100;
80
+ // NOTE on wall-clock bounds. The Go oracle carries a compile timeout, which it can
81
+ // enforce because its runtime preempts. This port carries none, deliberately: a
82
+ // CPU-bound spin blocks the event loop, so a timer never runs until the work it was
83
+ // meant to interrupt has finished. A control that cannot preempt the work it names is
84
+ // not a control. What bounds this port is entirely static and identical in all three
85
+ // languages: the size, depth and evaluation caps, and the pattern alphabet, which
86
+ // refuses the nested quantifiers that make backtracking catastrophic in the first
87
+ // place.
88
+ /**
89
+ * SchemaVerdict is the outcome of compiling a published data_schema. The tokens
90
+ * are the Go `SchemaVerdict.String()` vocabulary verbatim, which is what the
91
+ * shared vectors record — a union of string literals rather than a numeric enum,
92
+ * matching the sibling AudienceVerdict.
93
+ *
94
+ * "no_verdict" is Go's zero value and is never returned here; it is in the
95
+ * vocabulary because the corpus carries the whole vocabulary.
96
+ */
97
+ export const schemaVerdicts = [
98
+ "no_verdict",
99
+ "accepted",
100
+ "malformed",
101
+ "wrong_dialect",
102
+ "remote_ref",
103
+ "too_large",
104
+ "too_deep",
105
+ "unsafe_pattern",
106
+ "too_complex",
107
+ "ref_cycle",
108
+ "ref_chain_too_long",
109
+ "compile_timeout",
110
+ "uncompilable",
111
+ "not_published",
112
+ ];
113
+ // Escape letters every engine spells the same way AND reads the same way. This is an
114
+ // ALLOWLIST, and that is the point: the set of escapes the three engines disagree
115
+ // about is open-ended, so enumerating it means adding an entry every time somebody
116
+ // finds another one. See isSafeSchemaPattern for the full argument.
117
+ const portableEscapes = new Set("dDwWnrtfv".split(""));
118
+ // The regex metacharacters an author escapes to mean the character itself. Every
119
+ // engine accepts these — they are the characters that NEED escaping, so no dialect can
120
+ // refuse them. Escaping anything else is an "identity escape", and this port is the
121
+ // one that refuses those: ajv compiles every pattern with the `u` flag
122
+ // (`unicodeRegExp` defaults to true), under which ECMA-262 rejects `\-`, `\a` and the
123
+ // rest outright while RE2 and Python accept them.
124
+ const portableSyntaxEscapes = new Set("$()*+./?[\\]^{|}".split(""));
125
+ // The escapes standing for a SET of characters rather than one. A set cannot be the
126
+ // endpoint of a range, and the engines disagree about whether saying so ("[\w-x]") is
127
+ // an error or a reinterpretation.
128
+ const shorthandClassEscapes = new Set("dDwW".split(""));
129
+ /**
130
+ * correctedPatternSource rewrites one author-written regex into a source that means the
131
+ * SAME thing here as it does in RE2 and in Python's `re`.
132
+ *
133
+ * One correction, and it is this port's turn to make one: `.` excludes only `\n` in RE2
134
+ * and in Python, and excludes all four line terminators in ECMA-262, so `^.$` against a
135
+ * carriage return conformed in the other two SDKs and violated here. Refusing `.` would
136
+ * gut a construct that appears in most real patterns, so the odd one out is corrected
137
+ * instead — exactly what the Python port already does for `$`, which matches before a
138
+ * trailing newline there and nowhere else.
139
+ *
140
+ * The scan is bracket- and escape-aware: a `.` inside a character class is already a
141
+ * literal dot, and an escaped `\.` is a literal everywhere.
142
+ */
143
+ function correctedPatternSource(pattern) {
144
+ const out = [];
145
+ let inClass = false;
146
+ for (let i = 0; i < pattern.length; i++) {
147
+ const ch = pattern[i];
148
+ if (ch === "\\") {
149
+ out.push(pattern.slice(i, i + 2));
150
+ i++;
151
+ continue;
152
+ }
153
+ if (ch === "[")
154
+ inClass = true;
155
+ else if (ch === "]")
156
+ inClass = false;
157
+ else if (ch === "." && !inClass) {
158
+ out.push("[^\n]");
159
+ continue;
160
+ }
161
+ out.push(ch);
162
+ }
163
+ return out.join("");
164
+ }
165
+ /**
166
+ * authoredPatternSource recovers the author's own text from a corrected source, for a
167
+ * refusal an operator reads.
168
+ *
169
+ * The rewrite is reversed rather than tracked alongside, which is exact in the direction
170
+ * that matters: an author who wrote `.` sees `.` back. It is not a round trip in the
171
+ * other direction — an author who wrote `[^\n]` also sees `.` — but the two are the same
172
+ * set of characters once corrected, and `.` is overwhelmingly the form people write. The
173
+ * refusal prose is validator-defined by contract and deliberately not pinned by the
174
+ * shared corpus, so an equivalent spelling is within what that contract allows.
175
+ */
176
+ function authoredPatternSource(pattern) {
177
+ return pattern.split("[^\n]").join(".");
178
+ }
179
+ /**
180
+ * correctRegexes returns a copy of the document with every regex-valued position
181
+ * corrected.
182
+ *
183
+ * The walk mirrors the safety scan exactly, and for the same reasons: a `const` holds
184
+ * DATA whose contents are never read as keywords, and `properties`/`$defs` map NAMES to
185
+ * subschemas, so a property literally called "pattern" is a property name and not a
186
+ * regex. Correcting the SOURCE rather than the matcher reaches every place ajv compiles
187
+ * a regex — the `pattern` keyword, the `patternProperties` keys, and the matched-key
188
+ * scans behind `additionalProperties` and `unevaluatedProperties` — including any a
189
+ * later release adds.
190
+ */
191
+ function correctRegexes(node) {
192
+ if (Array.isArray(node))
193
+ return node.map(correctRegexes);
194
+ if (node === null || typeof node !== "object")
195
+ return node;
196
+ const out = {};
197
+ for (const [key, child] of Object.entries(node)) {
198
+ if (nonSchemaKeywords.has(key)) {
199
+ out[key] = child;
200
+ }
201
+ else if (key === "pattern" && typeof child === "string") {
202
+ out[key] = correctedPatternSource(child);
203
+ }
204
+ else if (key === "patternProperties" && child !== null && typeof child === "object") {
205
+ // The regexes are the KEYS here, which is why correcting the matcher for the
206
+ // `pattern` keyword alone would never reach them.
207
+ out[key] = Object.fromEntries(Object.entries(child).map(([k, v]) => [
208
+ correctedPatternSource(k),
209
+ correctRegexes(v),
210
+ ]));
211
+ }
212
+ else if (schemaMapKeywords.has(key) && child !== null && typeof child === "object") {
213
+ out[key] = Object.fromEntries(Object.entries(child).map(([n, sub]) => [n, correctRegexes(sub)]));
214
+ }
215
+ else {
216
+ out[key] = correctRegexes(child);
217
+ }
218
+ }
219
+ return out;
220
+ }
221
+ /**
222
+ * isJsonBlank reports whether `bytes` carries nothing but JSON whitespace, which is
223
+ * what "this Exchange publishes no data_schema" looks like in bytes.
224
+ *
225
+ * The definition is RFC 8259's and nothing wider: space, tab, carriage return and line
226
+ * feed. It is deliberately NOT each language's idea of whitespace, because that is
227
+ * three different sets — Go's `unicode.IsSpace` takes U+00A0 and U+3000, a Python
228
+ * `bytes.strip()` takes neither, and this runtime's `String.trim` takes both. This gate
229
+ * decides the ENFORCEMENT SWITCH: reading a document as blank means reading it as "no
230
+ * schema published", which turns validation off.
231
+ *
232
+ * It asks the question over the BYTES rather than a decoded string, which matters most
233
+ * here: `TextDecoder` strips a leading byte order mark, so decoding first made "mark
234
+ * followed by a space" look blank and returned `not_published` — silently bypassing the
235
+ * rule below that exists to refuse a mark, and turning enforcement off for a document
236
+ * the other two SDKs call malformed.
237
+ */
238
+ function isJsonBlank(bytes) {
239
+ for (const b of bytes) {
240
+ if (b !== 0x20 && b !== 0x09 && b !== 0x0d && b !== 0x0a)
241
+ return false;
242
+ }
243
+ return true;
244
+ }
245
+ /**
246
+ * hexEscapeLen reports the length of a `\xHH` escape at `i`, or 0 if there is not one
247
+ * there. Exactly two hex digits: `\x41` is read the same by all three engines, while
248
+ * the brace form `\x{41}` is an RE2 spelling the other two refuse and a short `\x4` is
249
+ * refused by all three.
250
+ */
251
+ function hexEscapeLen(pattern, i) {
252
+ if (pattern[i] !== "\\" || pattern[i + 1] !== "x" || i + 3 >= pattern.length)
253
+ return 0;
254
+ if (!/^[0-9a-fA-F]{2}$/.test(pattern.slice(i + 2, i + 4)))
255
+ return 0;
256
+ return 4;
257
+ }
258
+ /**
259
+ * isRangeHyphenAt reports whether the character at `i` is a "-" acting as a range
260
+ * operator inside a bracket expression, rather than the literal hyphen a class may
261
+ * end with ("[a-z-]").
262
+ */
263
+ function isRangeHyphenAt(pattern, i) {
264
+ return pattern[i] === "-" && i + 1 < pattern.length && pattern[i + 1] !== "]";
265
+ }
266
+ // Keywords whose value is arbitrary JSON DATA rather than a subschema. Their
267
+ // contents are never read as keywords, so a `const` carrying a "$ref" member is
268
+ // data. Their nesting is still bounded, by the lexical depth scan over the raw bytes.
269
+ const nonSchemaKeywords = new Set(["const", "default", "enum", "examples"]);
270
+ // Keywords that map NAMES to subschemas. Their child keys are property or
271
+ // definition names rather than keywords: {"properties": {"$ref": {...}}} declares
272
+ // a property called "$ref", not a reference.
273
+ const schemaMapKeywords = new Set([
274
+ "properties",
275
+ "patternProperties",
276
+ "$defs",
277
+ "definitions",
278
+ "dependentSchemas",
279
+ ]);
280
+ const referenceKeywords = ["$ref", "$dynamicRef", "$recursiveRef"];
281
+ /**
282
+ * The largest {n,m} bound admitted. RE2 refuses a repeat count over 1000 outright
283
+ * while the other two engines expand it, so a larger bound is a pattern one SDK
284
+ * compiles and another does not.
285
+ */
286
+ /**
287
+ * The largest {n,m} bound admitted. RE2 refuses a repeat count over 1000 outright while
288
+ * the other two engines expand it, so a larger bound is a pattern one SDK compiles and
289
+ * another does not. Exported so the parity suite can hold it against the corpus header:
290
+ * it and the escape alphabet are the two values that decide which patterns this port
291
+ * admits, and they were the two the suite did not check.
292
+ */
293
+ export const maxPortableRepeat = 1000;
294
+ /**
295
+ * The length of the counted repeat starting at `s`, including its closing brace, or 0
296
+ * if `s` opens none that every engine reads the same way. A `{` that opens no valid
297
+ * quantifier is refused rather than treated as a literal, because whether it IS a
298
+ * literal is precisely what the engines disagree about.
299
+ *
300
+ * It returns a LENGTH rather than a boolean so the caller can consume the whole
301
+ * `{n,m}`. That is what lets an unmatched `}` be refused: once every well-formed
302
+ * quantifier is stepped over, a `}` the scan still reaches closes nothing.
303
+ *
304
+ * The first bound must be present. `a{,5}` is the shape that forced this: RE2 reads it
305
+ * as the five literal characters and Python reads it as a repeat of zero to five, so
306
+ * both compile the pattern and then disagree about which payloads match it, with
307
+ * nothing logged. The empty part is still allowed AFTER the comma, because `{n,}` is
308
+ * the ordinary open-ended repeat and every engine agrees on it.
309
+ *
310
+ * The body is split on EVERY comma, not with a limit. `String.split`'s second argument
311
+ * caps the result array and DISCARDS the remainder, where Go's SplitN and Python's
312
+ * maxsplit keep it in the last element — so `"1,2,3".split(",", 2)` was `["1","2"]`
313
+ * here and `["1", "2,3"]` there, and this port alone admitted `a{1,2,3}`.
314
+ */
315
+ function quantifierLen(s) {
316
+ const end = s.indexOf("}");
317
+ if (end < 0)
318
+ return 0;
319
+ const body = s.slice(1, end);
320
+ if (body === "")
321
+ return 0;
322
+ const parts = body.split(",");
323
+ if (parts.length > 2)
324
+ return 0;
325
+ for (const [i, part] of parts.entries()) {
326
+ if (part === "") {
327
+ if (i === 0)
328
+ return 0; // "{,5}" — two engines, two readings
329
+ continue; // "{n,}" is well formed
330
+ }
331
+ if (!/^[0-9]+$/.test(part))
332
+ return 0;
333
+ if (Number(part) > maxPortableRepeat)
334
+ return 0;
335
+ }
336
+ return end + 1;
337
+ }
338
+ /** The text between the parenthesis at `open` and its match, plus the index past it. */
339
+ function groupBody(p, open) {
340
+ let depth = 0;
341
+ let inClass = false;
342
+ for (let i = open; i < p.length; i++) {
343
+ const ch = p[i];
344
+ if (ch === "\\") {
345
+ i++;
346
+ continue;
347
+ }
348
+ if (ch === "[")
349
+ inClass = true;
350
+ else if (ch === "]")
351
+ inClass = false;
352
+ else if (ch === "(" && !inClass)
353
+ depth++;
354
+ else if (ch === ")" && !inClass) {
355
+ depth--;
356
+ if (depth === 0)
357
+ return { body: p.slice(open + 1, i), after: i + 1 };
358
+ }
359
+ }
360
+ return null;
361
+ }
362
+ /** Remove escaped characters so an escaped metacharacter is not read as a quantifier. */
363
+ function stripEscapes(s) {
364
+ let out = "";
365
+ for (let i = 0; i < s.length; i++) {
366
+ if (s[i] === "\\") {
367
+ i++;
368
+ continue;
369
+ }
370
+ out += s[i];
371
+ }
372
+ return out;
373
+ }
374
+ /**
375
+ * Whether the pattern quantifies a group whose body can itself repeat or branch — the
376
+ * shape that makes a backtracking engine explore exponentially many ways to match one
377
+ * input.
378
+ *
379
+ * This is the half of the catastrophic-backtracking answer that excluding lookaround
380
+ * and backreferences does not cover: nested quantifiers need neither, and every
381
+ * classic form — `(a+)+`, `(a|a)*`, `([a-z]+)*`, `(?:a*)*` — sits comfortably inside
382
+ * the rest of the alphabet. It has to be STATIC: a regex spin blocks this runtime's
383
+ * event loop, so no timer here could stop one.
384
+ *
385
+ * Deliberately coarse — a quantified group whose body contains any of `* + ? { |`.
386
+ * Deciding whether a particular body is genuinely ambiguous is not decidable in
387
+ * general, and the conservative answer costs an author a rewrite while the permissive
388
+ * one costs a service its availability.
389
+ */
390
+ function hasNestedQuantifier(p) {
391
+ let inClass = false;
392
+ for (let i = 0; i < p.length; i++) {
393
+ const ch = p[i];
394
+ if (ch === "\\") {
395
+ i++;
396
+ continue;
397
+ }
398
+ if (ch === "[")
399
+ inClass = true;
400
+ else if (ch === "]")
401
+ inClass = false;
402
+ else if (ch === "(" && !inClass) {
403
+ const found = groupBody(p, i);
404
+ if (found === null)
405
+ continue;
406
+ // A non-capturing group's "?:" is syntax, not content.
407
+ const body = found.body.startsWith("?:") ? found.body.slice(2) : found.body;
408
+ const next = p[found.after];
409
+ if ((next === "*" || next === "+" || next === "?" || next === "{") &&
410
+ /[*+?{|]/.test(stripEscapes(body))) {
411
+ return true;
412
+ }
413
+ }
414
+ }
415
+ return false;
416
+ }
417
+ /**
418
+ * isSafeSchemaPattern reports whether a `pattern` uses only constructs all three SDK
419
+ * languages express identically, and none that make a backtracking engine explode.
420
+ *
421
+ * Draft 2020-12 patterns are ECMA-262, and the three SDKs run three different engines
422
+ * over them. Two distinct failures follow, and this function answers only the first.
423
+ *
424
+ * Some constructs one engine cannot express at all — RE2 has no lookaround, this
425
+ * runtime has no inline flags — so no care at the call site reconciles them and they
426
+ * are refused here. Others every engine compiles and then reads DIFFERENTLY: `\d` is
427
+ * Unicode-aware in Python and ASCII here and in RE2, and Python's `$` also matches
428
+ * before a trailing newline. Those are not refused — they appear in almost every real
429
+ * pattern — they are corrected in the port that diverges, which is Python.
430
+ *
431
+ * The escape rule is an ALLOWLIST rather than a list of the divergent escapes, because
432
+ * the divergent set is open-ended: two successive reviews found new counterexamples by
433
+ * trying, which is the signature of a rule stated from the wrong side. The portable set
434
+ * is small, closed and checkable, and the corpus carries it as data.
435
+ *
436
+ * The last rule is about availability rather than agreement: a quantified group whose
437
+ * body can repeat or branch is refused, because that is what makes backtracking
438
+ * catastrophic and no timer here could stop one.
439
+ */
440
+ export function isSafeSchemaPattern(pattern) {
441
+ let inClass = false;
442
+ for (let i = 0; i < pattern.length; i++) {
443
+ const ch = pattern[i];
444
+ if (ch === "\\") {
445
+ if (i + 1 >= pattern.length) {
446
+ // A trailing backslash is not a pattern any engine compiles.
447
+ return false;
448
+ }
449
+ if (hexEscapeLen(pattern, i) > 0) {
450
+ i += 3; // the whole \xHH is consumed, never re-read as syntax
451
+ continue;
452
+ }
453
+ const next = pattern[i + 1];
454
+ if (!portableEscapes.has(next) && !portableSyntaxEscapes.has(next))
455
+ return false;
456
+ // A range whose endpoint is a shorthand CLASS rather than a character —
457
+ // "[\w-x]". RE2 reads it as a range and compiles; Python and this runtime
458
+ // under the `u` flag both refuse it. Only the adjacency is refused.
459
+ if (inClass && shorthandClassEscapes.has(next) && isRangeHyphenAt(pattern, i + 2)) {
460
+ return false;
461
+ }
462
+ i++; // the escaped character is consumed, never re-read as syntax
463
+ continue;
464
+ }
465
+ // The mirror of the case above: "[a-\w]".
466
+ if (ch === "-" &&
467
+ inClass &&
468
+ isRangeHyphenAt(pattern, i) &&
469
+ pattern[i + 1] === "\\" &&
470
+ shorthandClassEscapes.has(pattern[i + 2] ?? "")) {
471
+ return false;
472
+ }
473
+ if (ch === "[") {
474
+ if (inClass) {
475
+ // A literal "[" inside a class — EXCEPT when it opens a POSIX name.
476
+ // "[:alpha:]" is a character class to RE2 and the literal characters
477
+ // ":alph" here: both compile, then match different strings.
478
+ if (pattern.startsWith("[:", i))
479
+ return false;
480
+ continue;
481
+ }
482
+ inClass = true;
483
+ let rest = pattern.slice(i + 1);
484
+ if (rest.startsWith("^"))
485
+ rest = rest.slice(1);
486
+ // "]" straight after the opening bracket is a literal in POSIX and an
487
+ // empty class in ECMA; the engines disagree about whether it compiles.
488
+ if (rest.startsWith("]") || rest === "")
489
+ return false;
490
+ if (pattern.startsWith("[[:", i))
491
+ return false;
492
+ }
493
+ else if (ch === "]") {
494
+ // An unmatched "]" is a literal to RE2 and a syntax error here.
495
+ if (!inClass)
496
+ return false;
497
+ inClass = false;
498
+ }
499
+ else if (ch === "(" && !inClass) {
500
+ if (pattern[i + 1] === "?") {
501
+ // Everything spelled "(?..." is refused except the non-capturing
502
+ // group, the only one all three engines write the same way.
503
+ if (pattern[i + 2] !== ":")
504
+ return false;
505
+ i += 2;
506
+ }
507
+ }
508
+ else if (ch === "{" && !inClass) {
509
+ const consumed = quantifierLen(pattern.slice(i));
510
+ if (consumed === 0)
511
+ return false;
512
+ i += consumed - 1; // consume through the closing brace
513
+ }
514
+ else if (ch === "}" && !inClass) {
515
+ // Every well-formed quantifier was stepped over above, so a "}" reached here
516
+ // closes nothing. RE2 and Python read it as a literal and ECMA-262 under the
517
+ // `u` flag refuses it outright — the same split the unmatched "]" rule exists
518
+ // for, so it gets the same answer. A literal brace is written "\}", which the
519
+ // alphabet admits.
520
+ return false;
521
+ }
522
+ }
523
+ // An unclosed class is a literal "[" to RE2 and a syntax error here.
524
+ if (inClass)
525
+ return false;
526
+ return !hasNestedQuantifier(pattern);
527
+ }
528
+ /** The 2020-12 identifier in the two spellings that name the same dialect. */
529
+ function isDialect(value) {
530
+ return value === registrationSchemaDialect || value === `${registrationSchemaDialect}#`;
531
+ }
532
+ /**
533
+ * Object keys in CODE POINT order.
534
+ *
535
+ * JavaScript's default sort compares UTF-16 code units, which orders an astral
536
+ * character BEFORE U+FFFF where Go and Python order it after. Because the scan
537
+ * answers with the FIRST fault it finds, that difference changed the compile verdict:
538
+ * a document with two faults under two keys answered `remote_ref` here and
539
+ * `wrong_dialect` in the other two.
540
+ */
541
+ function sortedKeys(o) {
542
+ return Object.keys(o).sort(cmp);
543
+ }
544
+ function isPlainObject(v) {
545
+ return typeof v === "object" && v !== null && !Array.isArray(v);
546
+ }
547
+ /** Per-object rules, in the fixed order a document breaking two must answer. */
548
+ function checkKeywords(obj) {
549
+ const dialect = obj["$schema"];
550
+ if (typeof dialect === "string" && !isDialect(dialect))
551
+ return "wrong_dialect";
552
+ for (const keyword of referenceKeywords) {
553
+ const ref = obj[keyword];
554
+ // A same-document reference starts at the document root ("#/$defs/x") or at
555
+ // an anchor in it ("#name"). Anything else names a resource this document
556
+ // does not carry, and resolving it is the fetch the contract forbids.
557
+ if (typeof ref === "string" && !ref.startsWith("#"))
558
+ return "remote_ref";
559
+ }
560
+ const pattern = obj["pattern"];
561
+ if (typeof pattern === "string" && !isSafeSchemaPattern(pattern))
562
+ return "unsafe_pattern";
563
+ // patternProperties states its regexes as KEYS, so they are checked here rather
564
+ // than by the generic pattern branch above.
565
+ const patternProperties = obj["patternProperties"];
566
+ if (isPlainObject(patternProperties)) {
567
+ for (const key of sortedKeys(patternProperties)) {
568
+ if (!isSafeSchemaPattern(key))
569
+ return "unsafe_pattern";
570
+ }
571
+ }
572
+ return "accepted";
573
+ }
574
+ /**
575
+ * Walk a keyword whose child object maps NAMES to subschemas. The child's keys are
576
+ * property or definition names, never keywords, so only its values are read as
577
+ * schemas.
578
+ */
579
+ function scanSchemaMap(child) {
580
+ if (!isPlainObject(child)) {
581
+ // Not the shape the keyword takes; walk it generically rather than guess. An
582
+ // invalid schema is the compiler's to reject.
583
+ return scan(child);
584
+ }
585
+ for (const name of sortedKeys(child)) {
586
+ const verdict = scan(child[name]);
587
+ if (verdict !== "accepted")
588
+ return verdict;
589
+ }
590
+ return "accepted";
591
+ }
592
+ /**
593
+ * Walk the decoded document once, enforcing the dialect, reference and pattern
594
+ * rules. Depth is NOT its business — rawNestingDepth owns that bound and has
595
+ * already run, which is what lets this walk recurse freely. The first failure
596
+ * decides.
597
+ */
598
+ function scan(node) {
599
+ if (Array.isArray(node)) {
600
+ for (const item of node) {
601
+ const verdict = scan(item);
602
+ if (verdict !== "accepted")
603
+ return verdict;
604
+ }
605
+ return "accepted";
606
+ }
607
+ if (!isPlainObject(node))
608
+ return "accepted";
609
+ const own = checkKeywords(node);
610
+ if (own !== "accepted")
611
+ return own;
612
+ // Sorted so a document with two faults answers the same way on every run and in
613
+ // every language.
614
+ for (const key of sortedKeys(node)) {
615
+ // A non-schema keyword's value is DATA. Its contents are not read as keywords
616
+ // at all, so a `const` carrying a "$ref" member is a value a payload may equal
617
+ // rather than a reference to resolve.
618
+ if (nonSchemaKeywords.has(key))
619
+ continue;
620
+ const child = node[key];
621
+ const verdict = schemaMapKeywords.has(key) ? scanSchemaMap(child) : scan(child);
622
+ if (verdict !== "accepted")
623
+ return verdict;
624
+ }
625
+ return "accepted";
626
+ }
627
+ // --- work bound -----------------------------------------------------------------
628
+ // Keywords holding a LIST of subschemas, each of which may be evaluated against the
629
+ // same instance. They are what makes cost multiply along a reference chain.
630
+ const branchKeywords = new Set(["anyOf", "oneOf", "allOf", "prefixItems"]);
631
+ // Keywords holding exactly one subschema.
632
+ const singleSubschemaKeywords = new Set([
633
+ "not", "if", "then", "else", "items", "contains", "propertyNames",
634
+ "additionalProperties", "unevaluatedProperties", "unevaluatedItems", "contentSchema",
635
+ ]);
636
+ // Saturation point: past the cap the exact value carries no information.
637
+ const costCeiling = maxRegistrationSchemaEvaluations + 1;
638
+ function addCost(a, b) {
639
+ if (a >= costCeiling || b >= costCeiling || a + b >= costCeiling)
640
+ return costCeiling;
641
+ return a + b;
642
+ }
643
+ /** Index every `$anchor` so a "#name" reference resolves without a second walk. */
644
+ function collectAnchors(node, out) {
645
+ if (Array.isArray(node)) {
646
+ for (const item of node)
647
+ collectAnchors(item, out);
648
+ return;
649
+ }
650
+ if (!isPlainObject(node))
651
+ return;
652
+ const anchor = node["$anchor"];
653
+ if (typeof anchor === "string" && !out.has(anchor))
654
+ out.set(anchor, node);
655
+ for (const key of sortedKeys(node))
656
+ collectAnchors(node[key], out);
657
+ }
658
+ class CostWalker {
659
+ #root;
660
+ #anchors = new Map();
661
+ #memo = new Map();
662
+ #onStack = new Set();
663
+ verdict = "accepted";
664
+ constructor(root) {
665
+ this.#root = root;
666
+ collectAnchors(root, this.#anchors);
667
+ }
668
+ fail(v) {
669
+ if (this.verdict === "accepted")
670
+ this.verdict = v;
671
+ return costCeiling;
672
+ }
673
+ /**
674
+ * The worst-case evaluations `node` can require against one instance, and the longest
675
+ * chain of `$ref` hops starting anywhere inside it.
676
+ *
677
+ * A boolean schema is one evaluation; an object schema is itself plus everything it
678
+ * can delegate to. The second number rides along because the reference bound is over
679
+ * the longest PATH, so something has to compute it, and this walk already follows
680
+ * every reference to its target — counting the two together is what keeps them
681
+ * consistent.
682
+ *
683
+ * `$defs` and `definitions` are deliberately NOT counted — they are reachable only
684
+ * through a reference, and counting them here as well would charge a shared
685
+ * definition once per declaration plus once per use.
686
+ */
687
+ cost(node, hops) {
688
+ if (this.verdict !== "accepted")
689
+ return { cost: costCeiling, below: 0 };
690
+ if (!isPlainObject(node))
691
+ return { cost: 1, below: 0 };
692
+ let total = 1;
693
+ let below = 0;
694
+ for (const key of sortedKeys(node)) {
695
+ const value = node[key];
696
+ if (nonSchemaKeywords.has(key) || key === "$defs" || key === "definitions")
697
+ continue;
698
+ if (referenceKeywords.includes(key)) {
699
+ if (typeof value === "string") {
700
+ const r = this.refCost(value, hops + 1);
701
+ total = addCost(total, r.cost);
702
+ // The reference is itself a hop, so the chain through it is one longer
703
+ // than whatever chain remains below its target.
704
+ below = Math.max(below, 1 + r.below);
705
+ }
706
+ }
707
+ else if (branchKeywords.has(key)) {
708
+ if (Array.isArray(value)) {
709
+ for (const item of value) {
710
+ const r = this.cost(item, hops);
711
+ total = addCost(total, r.cost);
712
+ below = Math.max(below, r.below);
713
+ }
714
+ }
715
+ else {
716
+ const r = this.cost(value, hops);
717
+ total = addCost(total, r.cost);
718
+ below = Math.max(below, r.below);
719
+ }
720
+ }
721
+ else if (singleSubschemaKeywords.has(key)) {
722
+ const r = this.cost(value, hops);
723
+ total = addCost(total, r.cost);
724
+ below = Math.max(below, r.below);
725
+ }
726
+ else if (schemaMapKeywords.has(key)) {
727
+ if (isPlainObject(value)) {
728
+ for (const name of sortedKeys(value)) {
729
+ const r = this.cost(value[name], hops);
730
+ total = addCost(total, r.cost);
731
+ below = Math.max(below, r.below);
732
+ }
733
+ }
734
+ else {
735
+ const r = this.cost(value, hops);
736
+ total = addCost(total, r.cost);
737
+ below = Math.max(below, r.below);
738
+ }
739
+ }
740
+ if (total >= costCeiling)
741
+ return { cost: costCeiling, below };
742
+ }
743
+ return { cost: total, below };
744
+ }
745
+ /**
746
+ * Count a reference's target once and remember it, and enforce the chain bound at
747
+ * this reference. A location already being counted is a cycle: its cost is not
748
+ * finite, and it is what makes this port abort rather than answer.
749
+ *
750
+ * The chain through this reference is `hops + below`: what it took to arrive, plus
751
+ * what remains beneath the target. Both halves are needed. A count that watches only
752
+ * the first half measures the walk instead of the document — it passes a chain split
753
+ * into short segments that are each entered from the root, and it answers the same
754
+ * graph differently depending on the order that document happens to list its
755
+ * references in.
756
+ */
757
+ refCost(ref, hops) {
758
+ const { facts, judge } = this.#refFacts(ref, hops);
759
+ if (!judge)
760
+ return facts;
761
+ // ONE check, at the reference site, so it is applied identically whether the
762
+ // target was just walked or was already known. Splitting it — once where a target
763
+ // is counted and again where the memo answers — states the same condition twice,
764
+ // and a condition stated twice is a condition neither statement is responsible
765
+ // for.
766
+ if (hops + facts.below > maxRegistrationSchemaRefHops) {
767
+ return { cost: this.fail("ref_chain_too_long"), below: facts.below };
768
+ }
769
+ return facts;
770
+ }
771
+ /**
772
+ * A reference target's facts, from the memo when the target has been counted before
773
+ * and by walking it otherwise.
774
+ *
775
+ * `judge` is false when the walk has already reached a verdict of its own — a cycle,
776
+ * an unresolvable reference, a chain past the recursion guard — and the caller should
777
+ * propagate that rather than measure a chain that no longer means anything.
778
+ */
779
+ #refFacts(ref, hops) {
780
+ const memo = this.#memo.get(ref);
781
+ if (memo !== undefined)
782
+ return { facts: memo, judge: true };
783
+ if (this.#onStack.has(ref)) {
784
+ return { facts: { cost: this.fail("ref_cycle"), below: 0 }, judge: false };
785
+ }
786
+ // After the cycle check, so a chain that closes on itself still reports the more
787
+ // specific diagnosis. `hops` is tracked explicitly rather than read off the size
788
+ // of #onStack: the two agree here, where the walk recurses and the set mirrors the
789
+ // path, and they do NOT agree in a port whose walk is iterative and whose
790
+ // equivalent set is a search frontier. The bound is a number in the contract, so
791
+ // the three SDKs compute the same quantity by construction, not by coincidence.
792
+ //
793
+ // This is NOT the bound — the bound is the hops+below check above, which is
794
+ // exact. It is a recursion guard: cost and refCost call each other, so without it
795
+ // a chain as long as the size cap admits would be walked as deep as it is long
796
+ // before anything refused it.
797
+ if (hops > maxRegistrationSchemaRefHops) {
798
+ return { facts: { cost: this.fail("ref_chain_too_long"), below: 0 }, judge: false };
799
+ }
800
+ const target = this.resolve(ref);
801
+ if (!target.ok) {
802
+ return { facts: { cost: this.fail("uncompilable"), below: 0 }, judge: false };
803
+ }
804
+ this.#onStack.add(ref);
805
+ const facts = this.cost(target.node, hops);
806
+ this.#onStack.delete(ref);
807
+ // A walk cut short by the cost ceiling memoises a partial `below`, which cannot
808
+ // produce a wrong accept: a saturated cost is maxRegistrationSchemaEvaluations+1,
809
+ // so the document is refused as too complex whatever its chain length.
810
+ this.#memo.set(ref, facts);
811
+ return { facts, judge: true };
812
+ }
813
+ /**
814
+ * Follow a same-document reference. The scan has already refused anything not
815
+ * beginning with "#", so only three forms reach here: the whole document, an RFC
816
+ * 6901 pointer into it, and a `$anchor` name.
817
+ */
818
+ resolve(ref) {
819
+ const frag = ref.startsWith("#") ? ref.slice(1) : ref;
820
+ if (frag === "")
821
+ return { node: this.#root, ok: true };
822
+ if (!frag.startsWith("/")) {
823
+ const target = this.#anchors.get(frag);
824
+ return { node: target, ok: this.#anchors.has(frag) };
825
+ }
826
+ let node = this.#root;
827
+ for (const raw of frag.slice(1).split("/")) {
828
+ const token = raw.replaceAll("~1", "/").replaceAll("~0", "~");
829
+ if (isPlainObject(node)) {
830
+ if (!(token in node))
831
+ return { node: undefined, ok: false };
832
+ node = node[token];
833
+ }
834
+ else if (Array.isArray(node)) {
835
+ if (!/^[0-9]+$/.test(token) || Number(token) >= node.length) {
836
+ return { node: undefined, ok: false };
837
+ }
838
+ node = node[Number(token)];
839
+ }
840
+ else {
841
+ return { node: undefined, ok: false };
842
+ }
843
+ }
844
+ return { node, ok: true };
845
+ }
846
+ }
847
+ /**
848
+ * Bound how much work validating a payload can cost. The size and depth caps bound
849
+ * the DOCUMENT and say nothing about this.
850
+ */
851
+ function checkEvaluationCost(doc) {
852
+ const walker = new CostWalker(doc);
853
+ const { cost } = walker.cost(doc, 0);
854
+ if (walker.verdict !== "accepted")
855
+ return walker.verdict;
856
+ return cost > maxRegistrationSchemaEvaluations ? "too_complex" : "accepted";
857
+ }
858
+ /**
859
+ * clampPointer keeps a pointer inside the wire's length bound WITHOUT truncating
860
+ * it. A pointer cut mid-token addresses a different member — or none — so an
861
+ * over-long one degrades to the longest ANCESTOR that fits.
862
+ */
863
+ function clampPointer(pointer) {
864
+ // CODE POINTS, not UTF-16 code units. protovalidate's string.max_len counts
865
+ // characters, and Go and Python both count them — so measuring `.length` here made
866
+ // this port clamp a pointer the other two left whole, and a pointer of 201 astral
867
+ // characters degraded all the way to the empty string, naming the root object where
868
+ // the others named the failing member.
869
+ const chars = [...pointer];
870
+ if (chars.length <= maxRegistrationFieldErrorPathLen)
871
+ return pointer;
872
+ for (let i = chars.length - 1; i > 0; i--) {
873
+ if (chars[i] === "/" && i <= maxRegistrationFieldErrorPathLen)
874
+ return chars.slice(0, i).join("");
875
+ }
876
+ return "";
877
+ }
878
+ /**
879
+ * clampText keeps the constraint text inside the wire's bound. The field also has
880
+ * a minimum of one character, so an empty description becomes a generic one rather
881
+ * than a message the contract would reject.
882
+ */
883
+ function clampText(text) {
884
+ if (!text)
885
+ return "does not conform to the published schema";
886
+ // CODE POINTS, for the same reason as clampPointer — and here a UTF-16 slice could
887
+ // also cut through a surrogate pair, emitting a lone surrogate where Go emits valid
888
+ // UTF-8. The wire counts characters; so does this.
889
+ const chars = [...text];
890
+ if (chars.length <= maxRegistrationFieldErrorTextLen)
891
+ return text;
892
+ return chars.slice(0, maxRegistrationFieldErrorTextLen).join("");
893
+ }
894
+ // Keywords whose constraint is a single schema-side bound worth stating verbatim.
895
+ const boundKeywords = new Set([
896
+ "exclusiveMaximum",
897
+ "exclusiveMinimum",
898
+ "maxContains",
899
+ "maxItems",
900
+ "maxLength",
901
+ "maxProperties",
902
+ "maximum",
903
+ "minContains",
904
+ "minItems",
905
+ "minLength",
906
+ "minProperties",
907
+ "minimum",
908
+ "multipleOf",
909
+ "pattern",
910
+ ]);
911
+ // Keywords whose constraint has no short value-free rendering, so the text names
912
+ // the rule instead. `enum` and `const` omit the allowed values: they come off the
913
+ // schema and would be safe, but they can be long and are not needed to act.
914
+ // `additionalProperties` omits the offending member NAMES, which come off the
915
+ // PAYLOAD — a member name an operator chose is as much their data as its value.
916
+ const fixedText = {
917
+ additionalProperties: "additional properties are not allowed",
918
+ allOf: "must match every branch of allOf",
919
+ anyOf: "must match at least one branch of anyOf",
920
+ const: "must equal the value the schema fixes",
921
+ contains: "must contain a matching item",
922
+ dependentRequired: "dependentRequired",
923
+ enum: "must be one of the values the schema enumerates",
924
+ not: "must not match the schema under not",
925
+ oneOf: "must match exactly one branch of oneOf",
926
+ propertyNames: "property name does not match propertyNames",
927
+ uniqueItems: "items must be unique",
928
+ };
929
+ /**
930
+ * describe turns a failure into (keyword, constraint text).
931
+ *
932
+ * It reads ONLY the constraint side. Ajv's own `error.message` is never used,
933
+ * because it quotes the offending value for several keywords, and the wire
934
+ * contract forbids a refusal from carrying the submitted value back out.
935
+ */
936
+ function describe(error) {
937
+ const keyword = error.keyword || "schema";
938
+ const params = error.params;
939
+ if (keyword === "required") {
940
+ // The name comes off the SCHEMA's required list, not off the payload — the
941
+ // value that failed is precisely the value that is absent.
942
+ const missing = params["missingProperty"];
943
+ return {
944
+ keyword,
945
+ text: typeof missing === "string" ? `required: ${missing}` : "required",
946
+ };
947
+ }
948
+ if (keyword === "type") {
949
+ const want = params["type"];
950
+ const wanted = Array.isArray(want) ? want : [want];
951
+ return { keyword, text: `must be of type ${wanted.join(" or ")}` };
952
+ }
953
+ if (keyword === "pattern") {
954
+ // The AUTHOR's pattern, not this port's corrected copy: an operator reading a
955
+ // refusal should see the regex their Exchange published.
956
+ return { keyword, text: `pattern: ${authoredPatternSource(String(params["pattern"]))}` };
957
+ }
958
+ if (boundKeywords.has(keyword)) {
959
+ // The bound comes off the schema, so naming it leaks nothing and is the one
960
+ // piece of detail that makes a refusal actionable.
961
+ const want = params["limit"] ?? params["pattern"] ?? params["multipleOf"];
962
+ return { keyword, text: `${keyword}: ${String(want)}` };
963
+ }
964
+ const fixed = fixedText[keyword];
965
+ if (fixed !== undefined)
966
+ return { keyword, text: fixed };
967
+ // Anything the table does not name still reports the keyword that failed, which
968
+ // is enough to act on and carries nothing off the payload by construction.
969
+ return { keyword, text: `does not satisfy ${keyword}` };
970
+ }
971
+ /**
972
+ * A compiled, accepted data_schema. Immutable and safe to share, so a server
973
+ * compiles the operator's schema once at start-up and a client caches one per
974
+ * Exchange.
975
+ */
976
+ export class RegistrationSchema {
977
+ #validate;
978
+ constructor(validate) {
979
+ this.#validate = validate;
980
+ }
981
+ /**
982
+ * Check a registration_data payload and name what failed. An empty array means
983
+ * the payload conforms.
984
+ *
985
+ * The result is ready for the refusal builder: `path` an RFC 6901 pointer
986
+ * relative to registration_data ("" addresses the whole object), `error` the
987
+ * violated CONSTRAINT and never the submitted value.
988
+ *
989
+ * The order is deterministic — entries are deduplicated by pointer and keyword,
990
+ * then sorted by both, before the list is capped. Three validators walk a
991
+ * failing document in three different orders, so an unsorted list is one no
992
+ * shared corpus could pin.
993
+ */
994
+ validate(data) {
995
+ return this.violations(data).map(({ path, error }) => ({ path, error: clampText(error) }));
996
+ }
997
+ /**
998
+ * The whole answer, keyword included. `validate` narrows it to the wire's
999
+ * two-field shape; the parity suite reads it whole, because the corpus pins the
1000
+ * keyword while `error` wording is validator-defined by contract.
1001
+ */
1002
+ violations(data) {
1003
+ const instance = data === null || data === undefined ? {} : data;
1004
+ if (this.#validate(instance))
1005
+ return [];
1006
+ const errors = this.#validate.errors ?? [];
1007
+ // Drop any error that has descendants AT THE SAME INSTANCE: a composite
1008
+ // keyword (oneOf, anyOf, if) reports itself AND the branch failures underneath
1009
+ // it, while Go's oracle reports only the leaves.
1010
+ //
1011
+ // Both halves of the key are load-bearing. Keying on schemaPath alone — the
1012
+ // first version of this — dropped a composite failure at one member because an
1013
+ // UNRELATED error at a DIFFERENT member happened to sit under the same shared
1014
+ // subschema, so a genuinely failing member vanished from the refusal and the
1015
+ // operator was never told to fix it. A composite that failed with no branch
1016
+ // errors (oneOf matching two branches) is still a leaf and survives.
1017
+ // A composite is a leaf only when nothing beneath it failed. Nesting is read off
1018
+ // the schema path, and a $ref BREAKS that nesting: the branch's own failure is
1019
+ // reported under "#/$defs/..." rather than under the anyOf, so an anyOf whose
1020
+ // branches are ALL $refs looked childless and survived beside the real error.
1021
+ // "one of several defined formats" is an ordinary registration shape, so the
1022
+ // hop is recognised rather than assumed away.
1023
+ const leaves = errors.filter((e) => !errors.some((o) => o !== e && isBeneath(o, e)));
1024
+ const flat = [];
1025
+ const seen = new Map();
1026
+ for (const error of leaves) {
1027
+ const { keyword, text } = describe(error);
1028
+ const path = clampPointer(error.instancePath);
1029
+ const key = `${path}${keyword}`;
1030
+ const at = seen.get(key);
1031
+ if (at !== undefined) {
1032
+ // Where a key repeats, the lexicographically smallest text wins, so the
1033
+ // prose does not depend on which duplicate was walked first.
1034
+ if (cmp(text, flat[at].error) < 0)
1035
+ flat[at].error = text;
1036
+ continue;
1037
+ }
1038
+ seen.set(key, flat.length);
1039
+ flat.push({ path, keyword, error: text });
1040
+ }
1041
+ flat.sort((a, b) => (a.path === b.path ? cmp(a.keyword, b.keyword) : cmp(a.path, b.path)));
1042
+ return flat.slice(0, maxRegistrationFieldErrors);
1043
+ }
1044
+ }
1045
+ // The keywords that fail as a WHOLE because every branch under them failed. Their own
1046
+ // error adds nothing an operator can act on once the branch failures are reported, so
1047
+ // it survives only when nothing beneath it did.
1048
+ const branchComposites = new Set(["anyOf", "oneOf"]);
1049
+ /**
1050
+ * isBeneath reports whether error `o` sits under error `e`, so that `e` is a composite
1051
+ * whose real cause is already reported and not a leaf.
1052
+ *
1053
+ * The schema-path test is the ordinary one. The composite test exists because a `$ref`
1054
+ * relocates the branch's failure out from under the composite — it is reported at the
1055
+ * definition it points to — leaving a nesting test nothing to match on.
1056
+ */
1057
+ function isBeneath(o, e) {
1058
+ if (o.instancePath !== e.instancePath)
1059
+ return false;
1060
+ if (o.schemaPath.startsWith(`${e.schemaPath}/`))
1061
+ return true;
1062
+ return branchComposites.has(e.keyword) && !branchComposites.has(o.keyword);
1063
+ }
1064
+ /**
1065
+ * CODE POINT comparison, matching Go's byte order over UTF-8 and Python's ordering
1066
+ * over str.
1067
+ *
1068
+ * `a < b` compares UTF-16 code units, which is NOT the same order: a surrogate pair
1069
+ * (U+10000 and above) begins with 0xD800-0xDBFF and therefore sorts before U+E000-
1070
+ * U+FFFF, where the other two languages sort it after. That difference is not
1071
+ * cosmetic here — the scan answers with the first fault it finds, so key order
1072
+ * decides the verdict.
1073
+ */
1074
+ function cmp(a, b) {
1075
+ const ac = [...a];
1076
+ const bc = [...b];
1077
+ const n = Math.min(ac.length, bc.length);
1078
+ for (let i = 0; i < n; i++) {
1079
+ const x = ac[i].codePointAt(0);
1080
+ const y = bc[i].codePointAt(0);
1081
+ if (x !== y)
1082
+ return x < y ? -1 : 1;
1083
+ }
1084
+ return ac.length - bc.length;
1085
+ }
1086
+ /**
1087
+ * compileRegistrationSchema checks a published data_schema against every rule and
1088
+ * compiles it.
1089
+ *
1090
+ * `raw` is the schema AS SERVED — the exact UTF-8 bytes of the data_schema member
1091
+ * in fora.json — because maxRegistrationSchemaBytes is defined over those bytes.
1092
+ *
1093
+ * The schema is null unless the verdict is "accepted". Nothing throws: every way
1094
+ * this can fail is a property of the schema, and both callers need to know WHICH.
1095
+ * They read the same refusal differently, and that difference is the contract. A
1096
+ * CLIENT pre-checking a payload treats any non-accepted verdict as "do not
1097
+ * pre-check" and sends anyway — the Exchange's enforcement is the deciding one, and
1098
+ * a client that refused here would block a payload the Exchange would have taken.
1099
+ * An EXCHANGE compiling its OWN configured schema treats the same verdict as an
1100
+ * operator misconfiguration, and must not advertise a schema it cannot enforce.
1101
+ */
1102
+ export function compileRegistrationSchema(raw) {
1103
+ const bytes = typeof raw === "string" ? new TextEncoder().encode(raw) : raw;
1104
+ // Nothing to compile is its own answer, not a malformed document: an Exchange that
1105
+ // publishes no data_schema is the contract's ordinary case.
1106
+ if (isJsonBlank(bytes)) {
1107
+ return { schema: null, verdict: "not_published" };
1108
+ }
1109
+ // Size first, on the bytes as served and before any parse: an oversized document
1110
+ // must not be decoded to find out that it was oversized.
1111
+ if (bytes.length > maxRegistrationSchemaBytes)
1112
+ return { schema: null, verdict: "too_large" };
1113
+ // A byte order mark is refused. RFC 8259 forbids adding one and lets a parser ignore
1114
+ // one, so both policies conform and the choice had to be made once for all three
1115
+ // SDKs: TextDecoder strips it by default and Python's json.loads strips it from
1116
+ // bytes, while Go's parser does not — so the same document compiled in two SDKs and
1117
+ // was malformed in the third. Refusing is the side that keeps the size cap above
1118
+ // honest, since a stripped mark would make it count three bytes the schema does not
1119
+ // contain, and a mark is only valid at the start of a JSON text, never inside the
1120
+ // fora.json member this schema lives in.
1121
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
1122
+ return { schema: null, verdict: "malformed" };
1123
+ }
1124
+ // Depth SECOND, and still on the raw bytes — before the document is handed to a
1125
+ // JSON parser rather than after. Every parser across the three SDKs descends
1126
+ // recursively, and two of them abort on a deeply nested document in a way this
1127
+ // face cannot map onto a verdict (Python raises RecursionError, which is not the
1128
+ // exception a malformed document raises), so a check placed after the parse is
1129
+ // reached only for documents harmless enough to parse. Lexical counting needs no
1130
+ // recursion at all.
1131
+ if (rawNestingDepth(bytes) > maxRegistrationSchemaDepth) {
1132
+ return { schema: null, verdict: "too_deep" };
1133
+ }
1134
+ let doc;
1135
+ try {
1136
+ doc = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
1137
+ }
1138
+ catch {
1139
+ return { schema: null, verdict: "malformed" };
1140
+ }
1141
+ // A JSON OBJECT and nothing else. 2020-12 admits a bare boolean as a schema, but
1142
+ // data_schema is a google.protobuf.Struct, which carries an object — so a boolean
1143
+ // cannot reach this face over the wire at all, and admitting one would pin
1144
+ // behaviour for a document the contract has no way to transport.
1145
+ if (!isPlainObject(doc)) {
1146
+ return { schema: null, verdict: "malformed" };
1147
+ }
1148
+ const verdict = scan(doc);
1149
+ if (verdict !== "accepted")
1150
+ return { schema: null, verdict };
1151
+ // The work bound, still before the library is involved. This is also where a
1152
+ // reference cycle and a same-document reference that resolves to nothing are
1153
+ // found, because counting the cost means following every reference to its target.
1154
+ const costVerdict = checkEvaluationCost(doc);
1155
+ if (costVerdict !== "accepted")
1156
+ return { schema: null, verdict: costVerdict };
1157
+ // strict:false because a published schema may legitimately carry keywords this
1158
+ // version of Ajv does not know, and the robustness principle applies to those
1159
+ // exactly as it does elsewhere in FORA. allErrors because a refusal names EVERY
1160
+ // offending member, not the first. validateFormats:false because format,
1161
+ // contentEncoding and contentMediaType stay ANNOTATIONS — the three languages'
1162
+ // libraries default differently, so leaving this to a default would make the
1163
+ // same document conform in one SDK and not in another.
1164
+ //
1165
+ // No loadSchema and no external resource is registered, deliberately: with none,
1166
+ // a reference this module's scan did not recognise fails closed at compile
1167
+ // instead of being fetched.
1168
+ const ajv = new Ajv2020({ strict: false, allErrors: true, validateFormats: false });
1169
+ try {
1170
+ // Every regex in the document, rewritten to mean here what it means in the other
1171
+ // two SDKs. This happens after every rule above, so what is scanned and bounded
1172
+ // as a schema is always the published document; only the copy handed to the
1173
+ // matcher carries the correction.
1174
+ const corrected = correctRegexes(doc);
1175
+ return {
1176
+ schema: new RegistrationSchema(ajv.compile(corrected)),
1177
+ verdict: "accepted",
1178
+ };
1179
+ }
1180
+ catch {
1181
+ return { schema: null, verdict: "uncompilable" };
1182
+ }
1183
+ }
1184
+ /**
1185
+ * maxRegistrationDataBytes bounds a submitted registration_data payload, measured as
1186
+ * its RFC 8785 canonical JSON encoding.
1187
+ *
1188
+ * The UNIT has to be named, and that is the whole point of this constant. Every other
1189
+ * cap in this module is over bytes a party actually served; registration_data is not
1190
+ * served as bytes at all — it arrives as a decoded google.protobuf.Struct — so "16KB"
1191
+ * means nothing until an encoding is chosen, and two implementations choosing privately
1192
+ * is the disagreement this module exists to remove. JCS is the choice because all three
1193
+ * SDKs already compute it for the signing primitive, and because it pins number
1194
+ * formatting: a payload carrying 1e300 is seven bytes to one renderer and three hundred
1195
+ * to another.
1196
+ *
1197
+ * It bounds WORK, not storage. The schema's own caps bound the schema; nothing bounded
1198
+ * the payload the schema is applied to, and validation cost is roughly the schema's cost
1199
+ * multiplied by the elements in the payload — a subschema under `items` is counted once
1200
+ * by maxRegistrationSchemaEvaluations and evaluated once per element.
1201
+ */
1202
+ export const maxRegistrationDataBytes = 16384;
1203
+ /**
1204
+ * maxRegistrationDataMembers bounds the number of members at the TOP LEVEL of a
1205
+ * payload. Top level rather than recursive, deliberately: nested bulk is already bounded
1206
+ * by the byte cap, and a recursive count would refuse a small document that merely
1207
+ * nests, which a business entity legitimately does (an address is an object).
1208
+ */
1209
+ export const maxRegistrationDataMembers = 64;
1210
+ /**
1211
+ * maxRegistrationDataDepth bounds how deeply a submitted registration_data payload may
1212
+ * nest, counting JSON containers so a bare `{}` is depth 1. Same number and same counting
1213
+ * rule as maxRegistrationSchemaDepth, because it is the same question asked of the other
1214
+ * document.
1215
+ *
1216
+ * It exists because without it the ANSWER depended on the reader's runtime rather than on
1217
+ * the payload. Canonicalising walks the payload recursively, and where that walk runs out
1218
+ * of stack differs by language and even by interpreter version — one port refused a
1219
+ * payload past about five hundred containers on one Python and accepted nine hundred on
1220
+ * the next, while this one and Go accepted every depth tried. A static bound checked
1221
+ * first turns that into one verdict every implementation reaches.
1222
+ */
1223
+ export const maxRegistrationDataDepth = 32;
1224
+ /**
1225
+ * RegistrationDataVerdict is the outcome of checking a submitted registration_data
1226
+ * payload. The tokens are the Go oracle's vocabulary verbatim.
1227
+ *
1228
+ * "no_verdict" is Go's zero value and is never returned here; it is in the vocabulary
1229
+ * because the corpus carries the whole vocabulary.
1230
+ */
1231
+ export const registrationDataVerdicts = [
1232
+ "no_verdict",
1233
+ "accepted",
1234
+ "too_large",
1235
+ "too_many_members",
1236
+ "too_deep",
1237
+ "uncanonicalizable",
1238
+ ];
1239
+ /**
1240
+ * registrationDataDepth returns how many JSON containers the payload nests, counting the
1241
+ * payload object itself as the first. A scalar is a value, not a container, so
1242
+ * `{"a":"x"}` nests one — the same rule the schema side applies, where the count is of a
1243
+ * document's opening braces and brackets and nothing else.
1244
+ *
1245
+ * Only containers are pushed, so every frame on the stack is one and the count needs no
1246
+ * test at the far end. The walk is ITERATIVE: it runs before the depth bound is known to
1247
+ * hold, so it is the one walk that must survive any input.
1248
+ */
1249
+ function registrationDataDepth(data) {
1250
+ const isContainer = (v) => Array.isArray(v) || (v !== null && typeof v === "object");
1251
+ let deepest = 0;
1252
+ const stack = [{ node: data, depth: 1 }];
1253
+ while (stack.length > 0) {
1254
+ const { node, depth } = stack.pop();
1255
+ if (depth > deepest)
1256
+ deepest = depth;
1257
+ // No need to walk past the bound: the answer is already decided, and a payload
1258
+ // deep enough to matter is also deep enough to be expensive to finish walking.
1259
+ if (depth > maxRegistrationDataDepth)
1260
+ return depth;
1261
+ let children = [];
1262
+ if (Array.isArray(node))
1263
+ children = node;
1264
+ else if (node !== null && typeof node === "object")
1265
+ children = Object.values(node);
1266
+ for (const child of children) {
1267
+ if (isContainer(child))
1268
+ stack.push({ node: child, depth: depth + 1 });
1269
+ }
1270
+ }
1271
+ return deepest;
1272
+ }
1273
+ /**
1274
+ * checkRegistrationData bounds a submitted registration_data payload.
1275
+ *
1276
+ * `data` is the decoded object. A null/undefined or empty payload is accepted: sending
1277
+ * no business data is a matter for the published schema's `required` list, not for a
1278
+ * size bound.
1279
+ *
1280
+ * This runs BEFORE RegistrationSchema.validate, for the same reason the schema's own
1281
+ * size cap runs before the schema is parsed: the bound exists to stop work, so it has to
1282
+ * precede the work. An Exchange refuses an over-bound payload outright — a malformed
1283
+ * request rather than a schema failure, so NOT
1284
+ * REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, which names non-conformance to
1285
+ * a published schema and applies only when one is published.
1286
+ */
1287
+ export function checkRegistrationData(data) {
1288
+ const obj = data ?? {};
1289
+ // Members first: it is a length check, and it bounds the document the canonical
1290
+ // encoding below then has to walk.
1291
+ if (Object.keys(obj).length > maxRegistrationDataMembers)
1292
+ return "too_many_members";
1293
+ // Depth SECOND, and before anything walks the payload recursively. The check is
1294
+ // iterative for that reason: a recursive one would hit the very limit it exists to
1295
+ // keep the caller away from, throwing while discovering that the payload is too deep.
1296
+ if (registrationDataDepth(obj) > maxRegistrationDataDepth)
1297
+ return "too_deep";
1298
+ let jcs;
1299
+ try {
1300
+ jcs = canonicalize(obj);
1301
+ }
1302
+ catch {
1303
+ // The reachable case is a non-finite number, which JSON cannot represent and
1304
+ // this canonicalizer throws on. It is a verdict rather than an exception because
1305
+ // this face, like the rest of the registration surface, does not throw.
1306
+ return "uncanonicalizable";
1307
+ }
1308
+ if (jcs === undefined)
1309
+ return "uncanonicalizable";
1310
+ // BYTES, not UTF-16 code units: the cap is over the canonical encoding, and a
1311
+ // non-ASCII member name costs more than one unit per character.
1312
+ if (new TextEncoder().encode(jcs).length > maxRegistrationDataBytes)
1313
+ return "too_large";
1314
+ return "accepted";
1315
+ }