@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,381 @@
1
+ // The Connect-unary JSON transport: POST /fora.v1.<Service>/<Method>, JSON body.
2
+ //
3
+ // Every FORA RPC is unary, so this is the whole protocol rather than a subset of it.
4
+ // Full Connect framing exists for streaming, which FORA has none of and would never use,
5
+ // and it would drag in a protobuf binary codec this SDK deliberately does not have — the
6
+ // Zod/Pydantic decision makes these clients JSON-only by design, not by omission.
7
+ //
8
+ // What this module owns: the URL, the header set, composing the signing face over the
9
+ // body bytes, the response-size bound, the deadline, refusing redirects, and turning a
10
+ // non-2xx into the client's typed failure. What it does NOT own: message shapes. Bodies
11
+ // are produced and parsed by the generated Zod schemas, which is the same path the
12
+ // canonical proto-JSON round-trip gate already proves loss-free against Go protojson.
13
+ import { signOutbound } from "../core/signing-transport.js";
14
+ import { errorDetailFrom } from "../src/errordetail.js";
15
+ import { MAX_BODY_DEPTH, rawNestingDepth } from "../src/jsondepth.js";
16
+ import { parseWire, WireNamingError } from "../../../gen/ts/wire/base.js";
17
+ import { requireScheme } from "../resolvers/http.js";
18
+ import { ConnectProtocolVersion, ConnectProtocolVersionHeader, ContentTypeJSON, RequestIDHeader, } from "../src/wire.js";
19
+ import { connectCodeFromStatus, kindOfConnectCode, malformed, ForaCallError, } from "./errors.js";
20
+ /**
21
+ * DEFAULT_MAX_RPC_READ_BYTES caps the response body a single FORA call will read.
22
+ *
23
+ * A FORA response for a realistic batch is small; the bound is what stops a peer —
24
+ * including one an offer named — spending the caller's memory on its behalf. Mirrors the
25
+ * Go client's DefaultMaxRPCReadBytes.
26
+ */
27
+ export const DEFAULT_MAX_RPC_READ_BYTES = 1 << 20; // 1 MiB
28
+ /**
29
+ * DEFAULT_CALL_TIMEOUT_MS bounds one call. A FORA RPC is interactive — something is
30
+ * waiting on the other end — so a request that has not answered by now is more useful as
31
+ * an error than as a hang. Mirrors the Go client's DefaultCallTimeout.
32
+ */
33
+ export const DEFAULT_CALL_TIMEOUT_MS = 30_000;
34
+ /**
35
+ * rpcURL joins an origin to the Connect unary path. The trailing slash is trimmed so a
36
+ * base URL written either way addresses the same method — a doubled slash is a different
37
+ * path to some servers and a 404 from them.
38
+ */
39
+ export function rpcURL(target) {
40
+ // Joined through URL, not concatenated: a base carrying a query or a fragment would
41
+ // otherwise swallow the RPC path — "https://x.test?a=1" + "/fora.v1.…" leaves the path
42
+ // inside the query string, and the call reaches the origin's root.
43
+ const base = target.baseURL.replace(/\/+$/, "");
44
+ const path = `/${target.service}/${target.method}`;
45
+ try {
46
+ const url = new URL(base);
47
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`;
48
+ url.search = "";
49
+ url.hash = "";
50
+ return url.toString();
51
+ }
52
+ catch {
53
+ // Not a URL this runtime can parse. Left to the send to refuse, which names the
54
+ // value; failing here would report it as something the caller could fix by
55
+ // changing the RPC instead of the address.
56
+ return `${base}${path}`;
57
+ }
58
+ }
59
+ /**
60
+ * unaryCall sends one FORA RPC and returns the peer's answer as parsed JSON.
61
+ *
62
+ * The body is serialized ONCE and the same bytes are signed and sent: RFC 9530
63
+ * Content-Digest covers the exact octets, so re-serializing between signing and sending
64
+ * would produce a digest for a body the peer never received.
65
+ */
66
+ export async function unaryCall(opts) {
67
+ const url = rpcURL(opts.target);
68
+ // The scheme, before anything is built for this call. It sits ABOVE the send because
69
+ // `send` is an injectable option: a caller that supplies one replaces the dial, and a
70
+ // gate living inside the default send would leave with it. Go states the same rule the
71
+ // other way round — a caller hands over what sits UNDER the guard, never what replaces
72
+ // it — and the delivery leg already gates here rather than at its dispatcher.
73
+ //
74
+ // Converted HERE rather than left to the catch below, because it runs before the try:
75
+ // refusing early is the point — a URL this client will not dial should cost no body
76
+ // encoding, no timer and no signature. requireScheme raises the resolvers' own
77
+ // SsrfBlockedError, which is deliberate, and this tier is what gives it a class. Every
78
+ // verb throws ForaCallError and nothing else; an untyped throw here is one a caller
79
+ // branching on that contract drops silently.
80
+ if (opts.guarded === true) {
81
+ try {
82
+ requireScheme(url);
83
+ }
84
+ catch (cause) {
85
+ // Unreachable, matching what the delivery leg answers for the identical refusal
86
+ // and what Go answers when the same check fires inside its RoundTripper.
87
+ throw new ForaCallError({ kind: "unreachable", op: opts.op, cause });
88
+ }
89
+ }
90
+ const body = encodeBody(opts.op, opts.message);
91
+ const headers = {
92
+ "content-type": ContentTypeJSON,
93
+ [ConnectProtocolVersionHeader]: ConnectProtocolVersion,
94
+ ...IDENTITY_ENCODING,
95
+ };
96
+ // Stamped BEFORE the signature so the covered headers are written last and nothing
97
+ // here can be mistaken for part of the proof. The correlation id is not covered and is
98
+ // not meant to be: it identifies the request in two sets of logs, it authorises
99
+ // nothing.
100
+ if (opts.requestId !== undefined)
101
+ headers[RequestIDHeader] = opts.requestId();
102
+ // The deadline COVERS the signature rather than starting after it. Signing may reach a
103
+ // custody backend, and a timer started afterwards would give the send a fresh full
104
+ // budget on top of whatever signing already spent — so "bounds one call" would mean
105
+ // something different depending on how slow custody was. It does not INTERRUPT signing:
106
+ // WebCrypto takes no signal, so what this bounds is the total, which is the property Go
107
+ // gets from passing one context through both. The content leg covers proof minting the
108
+ // same way, and Go pins that with a test of its own.
109
+ const controller = new AbortController();
110
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS);
111
+ let response;
112
+ try {
113
+ if (opts.signer !== undefined) {
114
+ Object.assign(headers, await signCall(opts.op, url, body, opts.signer));
115
+ }
116
+ response = await opts.send({
117
+ url,
118
+ headers,
119
+ body,
120
+ signal: controller.signal,
121
+ maxBytes: opts.maxBytes ?? DEFAULT_MAX_RPC_READ_BYTES,
122
+ op: opts.op,
123
+ });
124
+ }
125
+ catch (cause) {
126
+ throw asCallError(opts.op, cause);
127
+ }
128
+ finally {
129
+ clearTimeout(timer);
130
+ }
131
+ return decodeResponse(opts.op, response);
132
+ }
133
+ // encodeBody renders the message as the canonical proto-JSON bytes that get both signed
134
+ // and sent. The message is already a parsed generated-schema value, so JSON.stringify is
135
+ // the emission — no field names or enum spellings are decided here.
136
+ function encodeBody(op, message) {
137
+ let text;
138
+ try {
139
+ text = JSON.stringify(message ?? {});
140
+ }
141
+ catch (cause) {
142
+ throw malformed(op, cause);
143
+ }
144
+ return new TextEncoder().encode(text);
145
+ }
146
+ // signCall produces the RFC 9421 headers for this request. A custody failure is
147
+ // `not_signable`, matching what the content leg answers for the same missing holder: a
148
+ // caller branching on the kind sees one condition under one class, whichever verb met it
149
+ // first.
150
+ async function signCall(op, url, body, signer) {
151
+ try {
152
+ const signed = await signOutbound({
153
+ privKey: signer.privKey,
154
+ keyid: signer.keyid,
155
+ method: "POST",
156
+ url,
157
+ body,
158
+ authorization: "",
159
+ signatureAgent: signer.signatureAgent ?? "",
160
+ ...(signer.window !== undefined ? { window: signer.window } : {}),
161
+ });
162
+ return signed.headers;
163
+ }
164
+ catch (cause) {
165
+ throw new ForaCallError({ kind: "not_signable", op, cause });
166
+ }
167
+ }
168
+ // asCallError classifies a failure the send raised. A ForaCallError from the send (the
169
+ // size cap, most often) is already classified and passes through; anything else is a peer
170
+ // that did not answer.
171
+ function asCallError(op, cause) {
172
+ if (cause instanceof ForaCallError)
173
+ return cause;
174
+ return new ForaCallError({ kind: "unreachable", op, cause });
175
+ }
176
+ /**
177
+ * IDENTITY_ENCODING asks the peer for no content coding.
178
+ *
179
+ * Sending nothing is not the same thing: per RFC 9110 §12.5.3 an absent Accept-Encoding
180
+ * means ANY coding is acceptable, and undici does not decode one — so a gzipped answer
181
+ * would arrive as raw octets, fail to parse, and be reported as the peer's fault for
182
+ * something the peer was entitled to do.
183
+ */
184
+ export const IDENTITY_ENCODING = { "accept-encoding": "identity" };
185
+ /**
186
+ * refuseUnrequestedEncoding refuses a response carrying a content coding we did not ask for.
187
+ *
188
+ * Every leg negotiates identity, so a coding here is the peer answering that negotiation
189
+ * and then ignoring it. It is refused BEFORE the body is read, which is the only bound
190
+ * that holds at any chunk size — a decoder expands a whole raw read at once, so a running
191
+ * total over decoded chunks can be overshot by however much one chunk inflates to. The
192
+ * same check, for the same reason, is what bounds the Python client.
193
+ *
194
+ * `identity` itself is not a coding, and neither is an absent header.
195
+ */
196
+ export function refuseUnrequestedEncoding(op, status, headers) {
197
+ const raw = headers["content-encoding"];
198
+ const coding = (Array.isArray(raw) ? raw.join(",") : (raw ?? "")).trim().toLowerCase();
199
+ if (coding === "" || coding === "identity")
200
+ return;
201
+ throw new ForaCallError({
202
+ kind: "malformed",
203
+ op,
204
+ status,
205
+ cause: new Error(`peer answered with content-encoding "${coding}" after being asked for identity; ` +
206
+ "a coding this client did not negotiate cannot be read under a bound"),
207
+ });
208
+ }
209
+ /**
210
+ * decodeResponse turns one answer into a parsed message, or throws the typed failure.
211
+ *
212
+ * A non-2xx is the Connect error envelope: `{code, message, details}`. The typed reason
213
+ * rides in `details`, which errorDetailFrom reads — including the lowerCamelCase `debug`
214
+ * projection connect-go emits there and no server codec replaces.
215
+ */
216
+ export function decodeResponse(op, response) {
217
+ // A 3xx before anything is read out of the body. Every leg refuses to follow a
218
+ // redirect, so one reaching here is a server that did not answer rather than one that
219
+ // declined — and there is nothing in a redirect body to interpret. Unconditional on
220
+ // purpose: a 302 carrying a Connect envelope would otherwise be read as the peer's own
221
+ // verdict, and connect-go has no answer to mirror here because its transport follows
222
+ // redirects and never surfaces one.
223
+ if (response.status >= 300 && response.status < 400) {
224
+ throw new ForaCallError({
225
+ kind: "unreachable",
226
+ op,
227
+ status: response.status,
228
+ cause: new Error("peer answered with a redirect, which this client does not follow"),
229
+ });
230
+ }
231
+ const payload = parseJSON(op, response);
232
+ if (response.status < 200 || response.status >= 300) {
233
+ throw connectEnvelopeError(op, response.status, payload);
234
+ }
235
+ return payload;
236
+ }
237
+ function parseJSON(op, response) {
238
+ // Depth BEFORE the parse, and the bound is a CONTRACT rather than a property of this
239
+ // runtime. V8's JSON parser is iterative, so nothing here overflows a stack the way
240
+ // Python's reader does — but "how deep may a document this client did not write be" has
241
+ // to be one answer for both JSON clients, or a peer's answer is readable in one and
242
+ // refused in the other, and nothing says which is right. Go is not bounded here and
243
+ // deliberately so; docs/design-history.md records the difference.
244
+ //
245
+ // The scan is lexical and shared with the registration-schema compiler, which reaches
246
+ // for it against a runtime that does overflow. Counting needs no recursion.
247
+ if (rawNestingDepth(response.body) > MAX_BODY_DEPTH) {
248
+ throw new ForaCallError({
249
+ kind: "malformed",
250
+ op,
251
+ status: response.status,
252
+ cause: new Error(`answer nests deeper than ${MAX_BODY_DEPTH} containers`),
253
+ });
254
+ }
255
+ try {
256
+ return JSON.parse(response.body === "" ? "{}" : response.body);
257
+ }
258
+ catch (cause) {
259
+ // A non-2xx body that is not JSON did not come from the service: it is a gateway, a
260
+ // proxy or a load balancer answering for it. The STATUS is then the only thing that
261
+ // classifies it, which is what connect-go does with the same answer — and calling a
262
+ // momentary 502 malformed would put a retryable outage in the "this peer is broken"
263
+ // class. A 2xx that is not JSON is a different thing: the service claimed to answer
264
+ // and did not, which IS malformed.
265
+ if (response.status < 200 || response.status >= 300) {
266
+ const code = connectCodeFromStatus(response.status);
267
+ throw new ForaCallError({
268
+ kind: kindOfConnectCode(code),
269
+ op,
270
+ status: response.status,
271
+ reason: code,
272
+ cause,
273
+ });
274
+ }
275
+ throw new ForaCallError({
276
+ kind: "malformed",
277
+ op,
278
+ status: response.status,
279
+ cause,
280
+ });
281
+ }
282
+ }
283
+ function connectEnvelopeError(op, status, payload) {
284
+ const envelope = isRecord(payload) ? payload : {};
285
+ // An envelope carrying no code is not a verdict the peer reached, so the STATUS decides
286
+ // the class — which is what connect-go does with the same answer. Reporting a draining
287
+ // gateway's 503 as a refusal tells a caller not to retry a usage report that would
288
+ // succeed a moment later.
289
+ const named = typeof envelope["code"] === "string" ? envelope["code"] : "";
290
+ const code = named === "" ? connectCodeFromStatus(status) : named;
291
+ const detail = errorDetailFrom(envelope);
292
+ return new ForaCallError({
293
+ kind: kindOfConnectCode(code),
294
+ op,
295
+ status,
296
+ // The peer's own token, which is the Connect code here. A caller that wants more
297
+ // than the class reads the typed detail.
298
+ reason: code,
299
+ ...(detail !== null ? { detail } : {}),
300
+ // The one site that fills peerMessage, because this is the one site holding a
301
+ // detail the PEER emitted. Every other CallError leaves it empty — including
302
+ // the content leg, whose detail this SDK writes itself.
303
+ ...(detail?.message !== undefined && detail.message !== ""
304
+ ? { peerMessage: detail.message }
305
+ : {}),
306
+ cause: typeof envelope["message"] === "string" ? envelope["message"] : undefined,
307
+ });
308
+ }
309
+ function isRecord(v) {
310
+ return typeof v === "object" && v !== null && !Array.isArray(v);
311
+ }
312
+ /**
313
+ * NOT_CANONICAL_WIRE_NAMING is the reason a peer's answer is refused for spelling its
314
+ * field names the wrong way.
315
+ *
316
+ * It is the SDK's own verdict rather than a token the peer sent, which is the one place
317
+ * that happens: the peer's failure IS that it did not speak the contract, so there is no
318
+ * refusal of its own to carry. Go never produces it — it speaks binary proto, and
319
+ * protojson accepts both spellings on the way in — so this is a value only the JSON
320
+ * clients can observe, not a divergence in the shared failure taxonomy.
321
+ */
322
+ export const NOT_CANONICAL_WIRE_NAMING = "not_canonical_wire_naming";
323
+ /**
324
+ * validateRequest refuses a request the protocol would reject anyway, before it costs a
325
+ * signature and a round trip.
326
+ *
327
+ * The check is FIELD-level, which is what the generated schema carries: the cross-field
328
+ * CEL rules stay server-authoritative, so this is a smaller check than the Go client's
329
+ * protovalidate interceptor, not an equal one. What it does catch is the common case — a
330
+ * required recipient or idempotency key left unset — where the server's only possible
331
+ * answer is a refusal.
332
+ *
333
+ * The validated value is DISCARDED and the caller's own object is what gets sent: a parse
334
+ * fills declared defaults, and sending those would put fields on the wire the caller never
335
+ * set.
336
+ */
337
+ export function validateRequest(op, message, schema, validation) {
338
+ if (validation === "off")
339
+ return;
340
+ if (!parseUnderWirePolicy(op, message, schema).success) {
341
+ throw malformed(op, new Error("request failed its generated schema; the server could only refuse it"));
342
+ }
343
+ }
344
+ /**
345
+ * parseMessage validates one answer against its generated schema, after refusing a
346
+ * non-canonical spelling. The schema owns the field names, the enum NAME strings and the
347
+ * proto3 omit-unpopulated shape; nothing about the message is decided here.
348
+ */
349
+ export function parseMessage(op, raw, schema) {
350
+ const parsed = parseUnderWirePolicy(op, raw, schema);
351
+ if (!parsed.success) {
352
+ throw malformed(op, new Error("peer answer failed its generated schema"));
353
+ }
354
+ return parsed.data;
355
+ }
356
+ /**
357
+ * parseUnderWirePolicy runs the generated schema seam and turns its one refusal into this
358
+ * tier's typed failure.
359
+ *
360
+ * The wire policy — a null means the field has no value, and the lowerCamelCase
361
+ * json_name alias is refused at every depth — belongs to the schemas, so it lives with
362
+ * them in gen/ts/wire/base.ts and this tier only names what a refusal MEANS to a caller.
363
+ * Every parse of a generated schema in this SDK goes through here; a bare `safeParse`
364
+ * would skip the policy, which the no-direct-parse guard is there to catch.
365
+ */
366
+ function parseUnderWirePolicy(op, raw, schema) {
367
+ try {
368
+ return parseWire(schema, raw);
369
+ }
370
+ catch (cause) {
371
+ if (cause instanceof WireNamingError) {
372
+ throw new ForaCallError({
373
+ kind: "malformed",
374
+ op,
375
+ reason: NOT_CANONICAL_WIRE_NAMING,
376
+ cause,
377
+ });
378
+ }
379
+ throw cause;
380
+ }
381
+ }
@@ -0,0 +1,47 @@
1
+ /** One covered-component identifier: a lowercased name plus, for a forwarding
2
+ * chain link `"signature";key="sigN"`, the referenced predecessor label. */
3
+ export interface MultisigCovered {
4
+ name: string;
5
+ chainKey?: string;
6
+ }
7
+ /** One parsed Signature-Input dictionary member (one hop's label). `rawInner` is
8
+ * the VERBATIM value after `label=` — the exact @signature-params bytes the hop's
9
+ * signature base must terminate with (Go sigParams.RawInner). */
10
+ export interface MultisigMember {
11
+ label: string;
12
+ rawInner: string;
13
+ covered: MultisigCovered[];
14
+ keyid: string | null;
15
+ alg: string | null;
16
+ created?: number;
17
+ expires?: number;
18
+ }
19
+ /**
20
+ * Split one SFV dictionary header value on TOP-LEVEL commas, honoring quoted
21
+ * strings and their backslash escapes (Go splitTopLevelMembers). A comma inside a
22
+ * quoted keyid must NOT tear the member in two.
23
+ */
24
+ export declare function splitTopLevelMembers(s: string): string[];
25
+ /**
26
+ * The VERBATIM member value after `label=` for each label across the given
27
+ * Signature-Input header values (Go rawInnerByLabel). Later occurrences overwrite
28
+ * earlier ones, matching SFV dictionary last-wins semantics.
29
+ */
30
+ export declare function rawInnerByLabel(values: string[]): Record<string, string>;
31
+ /**
32
+ * Full multi-label parse of the Signature-Input header values, preserving header
33
+ * order. Returns undefined (clean reject) on any malformed member — never a
34
+ * mis-slice into a bogus covered set.
35
+ */
36
+ export declare function parseMultisigSignatureInput(values: string[]): MultisigMember[] | undefined;
37
+ /**
38
+ * Parse the RFC 9421 `Signature` header (`label=:<std-base64>:, …`) into a
39
+ * label→raw-bytes map — the per-label lookup the forwarding-chain link resolution
40
+ * and the multisig verify loop share (Go signatureBytesForLabel / parseSigLabel).
41
+ */
42
+ export declare function signatureBytesByLabel(sigHeader: string): Record<string, Uint8Array<ArrayBuffer>>;
43
+ /**
44
+ * The highest sigN label number present in a Signature-Input value (0 when none)
45
+ * — Go maxSignatureLabelN. Backs append's next-label / predecessor resolution.
46
+ */
47
+ export declare function maxSigLabelN(signatureInput: string): number;
@@ -0,0 +1,234 @@
1
+ // sdk/ts multi-member RFC 8941 Signature-Input dictionary parser + verbatim
2
+ // inner-per-label extractor — the TS port of Go helpers.parseAllSignatures /
3
+ // rawInnerByLabel / splitTopLevelMembers (verify.go). The single-sig verify path
4
+ // (core/verify-request.ts) hand-rolls a minimal ONE-label parser; the multisig
5
+ // forwarding chain needs the FULL multi-member dictionary parse plus the
6
+ // exact byte-verbatim inner value each hop's base terminates with.
7
+ //
8
+ // Dependency-light on purpose: no new runtime dep, mirroring the existing minimal
9
+ // single-label parsers. Correctness on ADVERSARIAL headers (quoted-comma,
10
+ // backslash escapes, top-level splitting, malformed reject) is pinned by
11
+ // tests/multisig-parse.edge.test.ts — the canonical Go golden vectors are
12
+ // well-behaved and do NOT gate the parser.
13
+ import { decodeBase64Url } from "../src/base64url.js";
14
+ /**
15
+ * Split one SFV dictionary header value on TOP-LEVEL commas, honoring quoted
16
+ * strings and their backslash escapes (Go splitTopLevelMembers). A comma inside a
17
+ * quoted keyid must NOT tear the member in two.
18
+ */
19
+ export function splitTopLevelMembers(s) {
20
+ const parts = [];
21
+ let start = 0;
22
+ let inQuote = false;
23
+ let escaped = false;
24
+ for (let i = 0; i < s.length; i += 1) {
25
+ const c = s[i];
26
+ if (escaped) {
27
+ escaped = false;
28
+ }
29
+ else if (c === "\\" && inQuote) {
30
+ escaped = true;
31
+ }
32
+ else if (c === '"') {
33
+ inQuote = !inQuote;
34
+ }
35
+ else if (c === "," && !inQuote) {
36
+ parts.push(s.slice(start, i));
37
+ start = i + 1;
38
+ }
39
+ }
40
+ parts.push(s.slice(start));
41
+ return parts;
42
+ }
43
+ /**
44
+ * The VERBATIM member value after `label=` for each label across the given
45
+ * Signature-Input header values (Go rawInnerByLabel). Later occurrences overwrite
46
+ * earlier ones, matching SFV dictionary last-wins semantics.
47
+ */
48
+ export function rawInnerByLabel(values) {
49
+ const out = {};
50
+ for (const v of values) {
51
+ for (const member of splitTopLevelMembers(v)) {
52
+ const eq = member.indexOf("=");
53
+ if (eq <= 0)
54
+ continue;
55
+ const label = member.slice(0, eq).trim();
56
+ out[label] = member.slice(eq + 1).trim();
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+ // findQuoteEnd returns the index of the closing quote for the quoted string that
62
+ // opens at `open`, honoring backslash escapes, or -1 if unterminated.
63
+ function findQuoteEnd(s, open) {
64
+ let escaped = false;
65
+ for (let i = open + 1; i < s.length; i += 1) {
66
+ const c = s[i];
67
+ if (escaped) {
68
+ escaped = false;
69
+ }
70
+ else if (c === "\\") {
71
+ escaped = true;
72
+ }
73
+ else if (c === '"') {
74
+ return i;
75
+ }
76
+ }
77
+ return -1;
78
+ }
79
+ // findInnerListEnd returns the index of the inner-list closing paren, honoring
80
+ // quoted strings (a param value may contain a paren), or -1 if unterminated.
81
+ function findInnerListEnd(s) {
82
+ let inQuote = false;
83
+ let escaped = false;
84
+ for (let i = 1; i < s.length; i += 1) {
85
+ const c = s[i];
86
+ if (escaped) {
87
+ escaped = false;
88
+ }
89
+ else if (c === "\\" && inQuote) {
90
+ escaped = true;
91
+ }
92
+ else if (c === '"') {
93
+ inQuote = !inQuote;
94
+ }
95
+ else if (c === ")" && !inQuote) {
96
+ return i;
97
+ }
98
+ }
99
+ return -1;
100
+ }
101
+ // parseCovered parses an inner-list body ("c1" "c2";key="v" …) into ordered
102
+ // covered components. Returns undefined on a malformed token.
103
+ function parseCovered(inner) {
104
+ const items = [];
105
+ let i = 0;
106
+ while (i < inner.length) {
107
+ if (inner[i] === " ") {
108
+ i += 1;
109
+ continue;
110
+ }
111
+ if (inner[i] !== '"')
112
+ return undefined;
113
+ const nameEnd = findQuoteEnd(inner, i);
114
+ if (nameEnd < 0)
115
+ return undefined;
116
+ const comp = { name: inner.slice(i + 1, nameEnd).toLowerCase() };
117
+ i = nameEnd + 1;
118
+ while (inner[i] === ";") {
119
+ const next = parseComponentParam(inner, i, comp);
120
+ if (next < 0)
121
+ return undefined;
122
+ i = next;
123
+ }
124
+ items.push(comp);
125
+ }
126
+ return items;
127
+ }
128
+ // parseComponentParam parses one `;key="val"` component parameter starting at
129
+ // `pos` (the ';'), stamping a chainKey onto comp for key="…". Returns the index
130
+ // after the param, or -1 on malformed.
131
+ function parseComponentParam(inner, pos, comp) {
132
+ const eq = inner.indexOf("=", pos);
133
+ if (eq < 0 || inner[eq + 1] !== '"')
134
+ return -1;
135
+ const pname = inner.slice(pos + 1, eq);
136
+ const vEnd = findQuoteEnd(inner, eq + 1);
137
+ if (vEnd < 0)
138
+ return -1;
139
+ if (pname === "key")
140
+ comp.chainKey = inner.slice(eq + 2, vEnd);
141
+ return vEnd + 1;
142
+ }
143
+ function matchQuoted(s, re) {
144
+ const m = s.match(re);
145
+ return m ? (m[1] ?? "") : null;
146
+ }
147
+ function matchInt(s, re) {
148
+ const m = s.match(re);
149
+ return m ? Number(m[1]) : undefined;
150
+ }
151
+ // parseMember parses one `label=(inner);params` member into a MultisigMember, or
152
+ // undefined on a malformed inner list / member.
153
+ function parseMember(raw) {
154
+ const eq = raw.indexOf("=");
155
+ if (eq <= 0)
156
+ return undefined;
157
+ const label = raw.slice(0, eq).trim();
158
+ const rawInner = raw.slice(eq + 1).trim();
159
+ if (rawInner[0] !== "(")
160
+ return undefined;
161
+ const close = findInnerListEnd(rawInner);
162
+ if (close < 0)
163
+ return undefined;
164
+ const covered = parseCovered(rawInner.slice(1, close));
165
+ if (!covered)
166
+ return undefined;
167
+ const tail = rawInner.slice(close + 1);
168
+ const created = matchInt(tail, /;created=(\d+)/);
169
+ const expires = matchInt(tail, /;expires=(\d+)/);
170
+ return {
171
+ label,
172
+ rawInner,
173
+ covered,
174
+ keyid: matchQuoted(tail, /;keyid="([^"]*)"/),
175
+ alg: matchQuoted(tail, /;alg="([^"]*)"/),
176
+ ...(created !== undefined ? { created } : {}),
177
+ ...(expires !== undefined ? { expires } : {}),
178
+ };
179
+ }
180
+ /**
181
+ * Full multi-label parse of the Signature-Input header values, preserving header
182
+ * order. Returns undefined (clean reject) on any malformed member — never a
183
+ * mis-slice into a bogus covered set.
184
+ */
185
+ export function parseMultisigSignatureInput(values) {
186
+ const members = [];
187
+ for (const v of values) {
188
+ for (const raw of splitTopLevelMembers(v)) {
189
+ const member = parseMember(raw);
190
+ if (!member)
191
+ return undefined;
192
+ members.push(member);
193
+ }
194
+ }
195
+ return members.length > 0 ? members : undefined;
196
+ }
197
+ /**
198
+ * Parse the RFC 9421 `Signature` header (`label=:<std-base64>:, …`) into a
199
+ * label→raw-bytes map — the per-label lookup the forwarding-chain link resolution
200
+ * and the multisig verify loop share (Go signatureBytesForLabel / parseSigLabel).
201
+ */
202
+ export function signatureBytesByLabel(sigHeader) {
203
+ const out = {};
204
+ for (const member of splitTopLevelMembers(sigHeader)) {
205
+ const eq = member.indexOf("=");
206
+ if (eq <= 0)
207
+ continue;
208
+ const label = member.slice(0, eq).trim();
209
+ const val = member.slice(eq + 1).trim();
210
+ const first = val.indexOf(":");
211
+ const last = val.lastIndexOf(":");
212
+ if (first < 0 || last <= first)
213
+ continue;
214
+ const bytes = decodeBase64Url(val.slice(first + 1, last));
215
+ if (bytes)
216
+ out[label] = bytes;
217
+ }
218
+ return out;
219
+ }
220
+ /**
221
+ * The highest sigN label number present in a Signature-Input value (0 when none)
222
+ * — Go maxSignatureLabelN. Backs append's next-label / predecessor resolution.
223
+ */
224
+ export function maxSigLabelN(signatureInput) {
225
+ let max = 0;
226
+ for (const label of Object.keys(rawInnerByLabel([signatureInput]))) {
227
+ if (!label.startsWith("sig"))
228
+ continue;
229
+ const n = Number(label.slice(3));
230
+ if (Number.isInteger(n) && n > max)
231
+ max = n;
232
+ }
233
+ return max;
234
+ }