@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,466 @@
1
+ // The content leg: fetching the bytes a signed delivery URL names, presenting the agent
2
+ // key that URL is bound to. TS port of sdk/go/resolvers/contentfetch.go.
3
+ //
4
+ // It lives in this tier because it DIALS, and a retrieval endpoint is chosen by a party
5
+ // on the network rather than by configuration — the exact threat shape the guard exists
6
+ // to contain. The transport-neutral tiers above stay free of any dialing surface.
7
+ import { Agent, request as undiciRequest } from "undici";
8
+ import { signInbound } from "../core/sign.js";
9
+ import { AGENT_KEY_HEADER } from "../src/pop.js";
10
+ import { retrievalAuthFailureDetail } from "../src/errordetail.js";
11
+ import { escapePair } from "../src/host-ref.js";
12
+ import { MAX_BODY_DEPTH, rawNestingDepth } from "../src/jsondepth.js";
13
+ import { RequestIDHeader } from "../src/wire.js";
14
+ import { IDENTITY_ENCODING, refuseUnrequestedEncoding } from "./transport.js";
15
+ import { requireScheme, skipSSRF, ssrfGuard } from "../resolvers/http.js";
16
+ import { ForaCallError } from "./errors.js";
17
+ import { concat, reclaim } from "./send.js";
18
+ /**
19
+ * DEFAULT_CONTENT_TIMEOUT_MS bounds one content fetch. An agent is blocked on the call
20
+ * that triggered it, so a fetch that has not answered by now is more useful as a reported
21
+ * failure than as a hang.
22
+ */
23
+ export const DEFAULT_CONTENT_TIMEOUT_MS = 30_000;
24
+ /**
25
+ * DEFAULT_MAX_CONTENT_BYTES caps one fetched body at 8 MiB. A memory bound on the
26
+ * fetching process, not a judgement about how large licensed content may be: the body is
27
+ * buffered whole and held for the life of the call, and a batch fetches one per item.
28
+ */
29
+ export const DEFAULT_MAX_CONTENT_BYTES = 8 << 20;
30
+ /** How much of a refusal body is read before the edge's reason is parsed out of it. The
31
+ * payload is a small JSON object; anything past this is not a refusal that can be
32
+ * interpreted. */
33
+ const MAX_ERROR_BODY_BYTES = 4 << 10;
34
+ /** What a body with no usable Content-Type is labelled. Guessing from the bytes would be
35
+ * worse: the caller is told what the publisher said, and "unknown" is a true answer where
36
+ * a sniffed guess might not be. */
37
+ const DEFAULT_CONTENT_MIME_TYPE = "application/octet-stream";
38
+ /**
39
+ * The shape a refusal token may have.
40
+ *
41
+ * The body this is read from is written by the host just fetched from, and the value is
42
+ * promoted over this SDK's own classification. Unchecked, a publisher could answer any
43
+ * 4 KiB of text and have it render as though the SDK had said it. Anything that is not
44
+ * token-shaped falls back to the failure class, which the SDK does own.
45
+ */
46
+ const EDGE_REASON_TOKEN = /^[a-z][a-z0-9_]{0,63}$/;
47
+ /** The ErrorDetail domain for a refusal by a delivery edge. It is the failing surface,
48
+ * not the fetched resource: the field is a stable grouping key for tooling, so it names
49
+ * the tier that refused. */
50
+ const EDGE_ERROR_DOMAIN = "fora.v1.Edge";
51
+ /** The range a TCP port has. A delivery URL naming one outside it names nothing
52
+ * reachable, whatever a URL parser makes of the value. */
53
+ const MIN_PORT = 1;
54
+ const MAX_PORT = 65535;
55
+ /** type "/" subtype over RFC 9110's token characters, lowercased. */
56
+ const MEDIA_TYPE_SHAPE = /^[!#$%&'*+.^_`|~0-9a-z-]+\/[!#$%&'*+.^_`|~0-9a-z-]+$/;
57
+ /**
58
+ * fetchContent retrieves the content at signedURL, presenting the proof of possession
59
+ * minted for it.
60
+ *
61
+ * Redirects are REFUSED. Following one would either replay a proof bound to the old URL,
62
+ * which the edge's own check rejects, or hand a fresh proof of possession of the agent's
63
+ * key to whatever host the first hop named.
64
+ *
65
+ * The URL is taken as given. Whether it is one this agent bought, and whether its
66
+ * agent_id matches this agent's key, are the CALLER's checks to make — the SDK exports
67
+ * verifyEd25519SignedUrl and the proof-of-possession verifier for exactly that, and
68
+ * running them first turns an edge 403 into a local answer. Worth doing when the URL
69
+ * reached the caller from anywhere but its own execute response: a proof is minted for
70
+ * whatever URL is passed in.
71
+ */
72
+ export async function fetchContent(signedURL, opts) {
73
+ const op = "fetch content";
74
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_CONTENT_BYTES;
75
+ const controller = new AbortController();
76
+ // The deadline covers proof minting, not just the round trip: minting may call out to
77
+ // a custody backend bounded only by that backend's own client, and a timeout covering
78
+ // the round trip alone would leave "bounds one content fetch" untrue against a
79
+ // degraded custody service. A batch pays that cost once per item.
80
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_CONTENT_TIMEOUT_MS);
81
+ try {
82
+ vetDialable(op, signedURL);
83
+ const proof = await mintProof(op, signedURL, opts);
84
+ const headers = {
85
+ [AGENT_KEY_HEADER]: proof.agentKey,
86
+ "signature-input": proof.signatureInput,
87
+ signature: proof.signature,
88
+ ...IDENTITY_ENCODING,
89
+ };
90
+ // Stamped BESIDE the proof rather than covered by it: the correlation id
91
+ // identifies the request in two sets of logs, it authorises nothing. It matters on
92
+ // THIS leg in particular — the RPC legs correlate through the transport, which a
93
+ // plain GET never traverses, so without it the delivery fetch is the one leg
94
+ // carrying no id and an edge that mints its own logs a refusal under a value
95
+ // nothing else knows.
96
+ if (opts.requestId !== undefined)
97
+ headers[RequestIDHeader] = opts.requestId();
98
+ const response = await undiciRequest(signedURL, {
99
+ method: "GET",
100
+ headers,
101
+ signal: controller.signal,
102
+ maxRedirections: 0,
103
+ // The guard is composed here and cannot be handed in already built: a caller
104
+ // supplies what sits UNDER it, never what replaces it. A delivery URL names a
105
+ // host chosen by another party, so the address pin applies in every case.
106
+ dispatcher: opts.dispatcher ?? sharedContentDispatcher(),
107
+ }).catch((cause) => {
108
+ throw dialFailure(op, cause);
109
+ });
110
+ try {
111
+ // Before either branch reads a byte, refusal included: its 4 KiB bound is the
112
+ // tightest here, so it is the one an unnegotiated coding overshoots furthest.
113
+ refuseUnrequestedEncoding(op, response.statusCode, response.headers);
114
+ // A 3xx reached this client only because the dial refused to follow it, so it
115
+ // is a server that did not answer rather than one that declined — the class
116
+ // every failure taxonomy here already documents for a redirect. Checked BEFORE
117
+ // the refusal reader, which would otherwise promote a token out of the redirect
118
+ // body: a 302 carrying {"reason":"moved"} surfaced as though the edge had named
119
+ // a typed refusal.
120
+ if (response.statusCode >= 300 && response.statusCode < 400) {
121
+ throw new ForaCallError({
122
+ kind: "unreachable",
123
+ op,
124
+ status: response.statusCode,
125
+ cause: new Error("peer answered with a redirect, which this client does not follow"),
126
+ });
127
+ }
128
+ if (response.statusCode < 200 || response.statusCode >= 300) {
129
+ throw await edgeRefusal(op, response.statusCode, response.body);
130
+ }
131
+ const body = await readBody(op, response.body, maxBytes);
132
+ return {
133
+ url: signedURL,
134
+ mimeType: mimeTypeOf(headerValue(response.headers, "content-type")),
135
+ body,
136
+ };
137
+ }
138
+ catch (failure) {
139
+ // The answer arrived, and then went wrong: the deadline fired mid-body, or the
140
+ // connection reset under the read. Both raise the dialing library's own error
141
+ // out of the loop, and this leg had no catch of its own — so a caller branching
142
+ // on the contract this package states, that every verb raises ForaCallError and
143
+ // nothing else, silently dropped it. Measured before this: a mid-body deadline
144
+ // surfaced as a bare DOMException. The RPC legs route the identical case through
145
+ // their own classifier; this is that, plus the redaction only this leg needs,
146
+ // because a delivery URL carries a live credential in its query.
147
+ throw failure instanceof ForaCallError ? failure : dialFailure(op, failure);
148
+ }
149
+ finally {
150
+ // Every exit, not every throw — including a future one that returns early.
151
+ reclaim(response.body);
152
+ }
153
+ }
154
+ finally {
155
+ clearTimeout(timer);
156
+ }
157
+ }
158
+ /**
159
+ * vetDialable refuses a delivery URL this SDK will not dial, before anything is spent on
160
+ * it — which is what makes mintProof's "after the URL has been accepted as dialable" true.
161
+ *
162
+ * THE VALUE'S OWN FAULTS ARE MALFORMED; the dial's refusals are unreachable. That split is
163
+ * the rule all three languages state, and it is stated rather than delegated to whichever
164
+ * URL parser the language ships — the three parsers disagree about what "unparseable" even
165
+ * means, and a class that changes with the parser is not a contract. Measured before this:
166
+ * every refusal here answered unreachable, so a URL that can never parse sat in the
167
+ * retryable class, and it wore the address guard's own reason while doing it.
168
+ *
169
+ * A value fault is: the URL does not parse, it names no scheme or no host, it names a port
170
+ * outside the range a port has, it carries a malformed percent-escape where a parse would
171
+ * unescape one, or it does not re-serialize to itself (mintProof's check, which runs next).
172
+ * Each will refuse identically forever, so a caller must fix the value.
173
+ *
174
+ * The dial's are the rest. The dispatcher's guard is the CONNECTOR: it decides what a
175
+ * hostname may resolve to and never sees a scheme, because by then the URL is already a
176
+ * host and a port. A delivery URL names a host another party chose, carries a live
177
+ * credential in its query and presents a proof of possession of the agent's key in a
178
+ * header, so plaintext here hands both to anyone on the path. Reported as unreachable,
179
+ * matching Go: there the same refusal comes out of the transport, where Fetch reads it as
180
+ * a dial that did not happen.
181
+ *
182
+ * The URL is never echoed, on either side: it carries a live credential in its query.
183
+ */
184
+ function vetDialable(op, signedURL) {
185
+ let parsed;
186
+ try {
187
+ // WHATWG `new URL` with no base rejects a value naming no scheme, and one naming no
188
+ // host, in the same throw — which is the rule's first two clauses together rather
189
+ // than a parser quirk being inherited.
190
+ parsed = new URL(signedURL);
191
+ }
192
+ catch {
193
+ throw malformedURL(op, "url is unparseable, or names no scheme or no host");
194
+ }
195
+ // A port outside the range a port has. WHATWG rejects a non-numeric one and one past
196
+ // 65535, and admits 0 — which is a port nothing listens on. Checked rather than left to
197
+ // the parse, because the rule is the rule and not whatever each language's parser
198
+ // happens to refuse; Go and Python check the same range over their own parsers.
199
+ if (parsed.port !== "") {
200
+ const port = Number(parsed.port);
201
+ if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) {
202
+ throw malformedURL(op, `url names a port outside ${MIN_PORT}-${MAX_PORT}`);
203
+ }
204
+ }
205
+ // Read PER COMPONENT, the way the host rule already reads it: a malformed escape is
206
+ // refused in a path and in a fragment, and admitted in a query, which a parse does not
207
+ // unescape — and a delivery URL's query is exactly where a credential lives. The
208
+ // predicate is the host rule's own, imported rather than restated.
209
+ if (escapePair.test(parsed.pathname) || escapePair.test(parsed.hash)) {
210
+ throw malformedURL(op, "url carries a malformed percent-escape");
211
+ }
212
+ try {
213
+ requireScheme(signedURL);
214
+ }
215
+ catch (cause) {
216
+ throw dialFailure(op, cause);
217
+ }
218
+ }
219
+ // malformedURL names a fault in the VALUE without naming the value: a delivery URL's query
220
+ // is a live credential, and this reaches a log.
221
+ function malformedURL(op, what) {
222
+ return new ForaCallError({
223
+ kind: "malformed",
224
+ op,
225
+ cause: new Error(`${what} (value withheld: it carries a live credential)`),
226
+ });
227
+ }
228
+ // mintProof signs a GET of the target through the shipped RFC 9421 proof-of-possession
229
+ // signer. It reuses core/sign.ts rather than restating the covered-component set, so the
230
+ // bytes stay identical to what the edge's verifier reconstructs.
231
+ //
232
+ // The proof is minted AFTER the URL has been accepted as dialable and BEFORE anything is
233
+ // sent, so a URL that cannot be used never costs a signing operation.
234
+ async function mintProof(op, signedURL, opts) {
235
+ let request;
236
+ try {
237
+ request = await signInbound(opts.keyPair, signedURL, opts.window !== undefined ? { window: opts.window } : {});
238
+ }
239
+ catch (cause) {
240
+ // The given URL is deliberately NOT echoed: this error reaches a log, and a
241
+ // delivery URL carries a live credential in its query.
242
+ throw new ForaCallError({ kind: "not_signable", op, cause: redact(cause) });
243
+ }
244
+ // The proof covers @target-uri as the VERBATIM string, while the request line carries
245
+ // whatever the URL re-serializes to. The signed-URL contract treats scheme/host/path
246
+ // as opaque bytes, so an Exchange can legitimately mint a URL those two disagree on —
247
+ // a raw space in the path is the reachable case. The signature then cannot verify and
248
+ // the edge reports only an undifferentiated 403, so refusing here names the cause.
249
+ if (request.url !== signedURL) {
250
+ throw new ForaCallError({
251
+ kind: "malformed",
252
+ op,
253
+ cause: new Error("url is not round-trip stable: it re-serializes to a different value (value withheld: it carries a live credential)"),
254
+ });
255
+ }
256
+ return {
257
+ agentKey: request.headers.get(AGENT_KEY_HEADER) ?? "",
258
+ signatureInput: request.headers.get("signature-input") ?? "",
259
+ signature: request.headers.get("signature") ?? "",
260
+ };
261
+ }
262
+ // edgeRefusal builds the failure for an edge that answered and said no, promoting the
263
+ // edge's own refusal token to a typed protocol reason when the vocabularies line up.
264
+ //
265
+ // The detail is SYNTHESIZED here rather than received: a delivery edge answers a small
266
+ // JSON object, not a protobuf. Its domain names the delivery edge as the failing SURFACE
267
+ // — a stable grouping, matching the value the cross-language error-detail corpus already
268
+ // uses for this reason block. Deliberately not the fetched URL: domain mirrors
269
+ // google.rpc.ErrorInfo.domain so generic tooling can group errors, and a per-URL value
270
+ // has unbounded cardinality and groups nothing.
271
+ async function edgeRefusal(op, status, body) {
272
+ const token = await edgeReason(body);
273
+ const init = {
274
+ kind: "refused",
275
+ op,
276
+ status,
277
+ ...(token !== "" ? { reason: token } : {}),
278
+ };
279
+ if (token === "")
280
+ return new ForaCallError(init);
281
+ const detail = retrievalAuthFailureDetailOrUndefined(token);
282
+ return new ForaCallError(detail !== undefined ? { ...init, detail } : init);
283
+ }
284
+ /** The edge's refusal token, mapped to its typed reason.
285
+ *
286
+ * The two vocabularies are separate on purpose — the edge is a code-capable worker with
287
+ * no protobuf runtime, so it answers a string — and `fora.proto` records which token each
288
+ * enum value stands for, beside the value. This table IS that record; it is not derived
289
+ * from the enum's own spelling, and deriving it would be wrong: `expired` names
290
+ * URL_EXPIRED, `pop_expired` names PROOF_EXPIRED, and neither is the enum suffix
291
+ * lowercased. A port that computed the name instead of reading the record typed two of
292
+ * the eleven tokens and silently dropped the rest.
293
+ *
294
+ * `missing_sig` is deliberately ABSENT rather than guessed at: both checkers emit it —
295
+ * the signed-URL check for a missing `sig` parameter and the proof check for a missing
296
+ * Signature header — and the enum has a distinct value for each, so the body does not say
297
+ * which ran. An unmapped token still reaches the caller as the raw refusal string; only
298
+ * the typed reason is withheld, which is the honest outcome when the wire cannot say
299
+ * which failure occurred. The edge's parse-level tokens have no enum value at all. */
300
+ const EDGE_REASON_TOKENS = {
301
+ // Signed-URL checks.
302
+ expired: "RETRIEVAL_AUTH_FAILURE_REASON_URL_EXPIRED",
303
+ missing_exp: "RETRIEVAL_AUTH_FAILURE_REASON_URL_EXPIRY_MISSING",
304
+ signature_mismatch: "RETRIEVAL_AUTH_FAILURE_REASON_URL_SIGNATURE_MISMATCH",
305
+ // Proof-of-possession checks.
306
+ missing_agent_key: "RETRIEVAL_AUTH_FAILURE_REASON_AGENT_KEY_MISSING",
307
+ keyid_mismatch: "RETRIEVAL_AUTH_FAILURE_REASON_KEYID_MISMATCH",
308
+ thumbprint_mismatch: "RETRIEVAL_AUTH_FAILURE_REASON_THUMBPRINT_MISMATCH",
309
+ pop_missing_created: "RETRIEVAL_AUTH_FAILURE_REASON_PROOF_CREATED_MISSING",
310
+ pop_missing_exp: "RETRIEVAL_AUTH_FAILURE_REASON_PROOF_EXPIRY_MISSING",
311
+ pop_expired: "RETRIEVAL_AUTH_FAILURE_REASON_PROOF_EXPIRED",
312
+ pop_sig_invalid: "RETRIEVAL_AUTH_FAILURE_REASON_PROOF_SIGNATURE_INVALID",
313
+ };
314
+ // retrievalAuthFailureDetailOrUndefined promotes an edge token to the typed reason when
315
+ // the protocol records one for it, and answers undefined otherwise. The edge's vocabulary
316
+ // and the protocol's are deliberately separate, and this is the single place they meet.
317
+ function retrievalAuthFailureDetailOrUndefined(token) {
318
+ const named = EDGE_REASON_TOKENS[token];
319
+ if (named === undefined)
320
+ return undefined;
321
+ try {
322
+ return retrievalAuthFailureDetail(EDGE_ERROR_DOMAIN, `delivery refused: ${token}`, named);
323
+ }
324
+ catch {
325
+ return undefined;
326
+ }
327
+ }
328
+ // edgeReason pulls the edge's own refusal token out of a rejection body. The edge answers
329
+ // {"error": "...", "reason": "..."} on a binding failure; anything else yields "".
330
+ //
331
+ // Bounded at the same depth the response reader bounds, and for the same reason: the two
332
+ // JSON clients must answer one thing for a document neither of them wrote. The 4 KiB cap
333
+ // above does not bound the nesting — 4 KiB of "[" nests four thousand deep — and Python's
334
+ // parser raises out of a reader that promises a value at a depth that varies by release.
335
+ //
336
+ // A body past the bound yields "" rather than a new failure: the fetch has already been
337
+ // refused by the edge, and the token is the one part of that refusal the SDK does not own.
338
+ // Falling back to the failure class is what this reader already answers for any body it
339
+ // cannot interpret.
340
+ async function edgeReason(body) {
341
+ let text;
342
+ try {
343
+ text = await readText(body, MAX_ERROR_BODY_BYTES);
344
+ }
345
+ catch {
346
+ return "";
347
+ }
348
+ if (rawNestingDepth(text) > MAX_BODY_DEPTH)
349
+ return "";
350
+ try {
351
+ const payload = JSON.parse(text);
352
+ const reason = typeof payload === "object" && payload !== null
353
+ ? payload["reason"]
354
+ : undefined;
355
+ return typeof reason === "string" && EDGE_REASON_TOKEN.test(reason)
356
+ ? reason
357
+ : "";
358
+ }
359
+ catch {
360
+ return "";
361
+ }
362
+ }
363
+ // readBody consumes the content under the configured cap, one byte past it so an
364
+ // oversized body is DETECTED rather than silently truncated. Truncated content that looks
365
+ // whole is worse than a refusal: the caller has paid for it and has no way to tell.
366
+ async function readBody(op, body, maxBytes) {
367
+ const chunks = [];
368
+ let total = 0;
369
+ for await (const chunk of body) {
370
+ total += chunk.length;
371
+ if (total > maxBytes) {
372
+ throw new ForaCallError({
373
+ kind: "too_large",
374
+ op,
375
+ cause: new Error(`body exceeds the ${maxBytes} byte cap`),
376
+ });
377
+ }
378
+ chunks.push(chunk);
379
+ }
380
+ return concat(chunks, total);
381
+ }
382
+ async function readText(body, maxBytes) {
383
+ const chunks = [];
384
+ let total = 0;
385
+ for await (const chunk of body) {
386
+ if (total >= maxBytes)
387
+ break;
388
+ const take = chunk.subarray(0, maxBytes - total);
389
+ total += take.length;
390
+ chunks.push(take);
391
+ }
392
+ return new TextDecoder().decode(concat(chunks, total));
393
+ }
394
+ /**
395
+ * mimeTypeOf reduces a Content-Type to its media type, dropping parameters such as
396
+ * charset. The charset belongs to whoever decodes the bytes; the content carries the
397
+ * media type alone.
398
+ *
399
+ * The parameters are DISCARDED WITHOUT BEING PARSED, and the media type's shape is checked
400
+ * rather than delegated to a parser, because the three SDKs must answer one thing for one
401
+ * header. Go's stdlib parser reports a malformed PARAMETER as an error beside a perfectly
402
+ * good media type — honouring that answers "unknown" for `text/plain; ;`, discarding the one
403
+ * thing the field carries because of the part this is defined to ignore — and it accepts a
404
+ * bare token with no slash, which is not a media type at all. The rule is stated instead:
405
+ * the text before the first `;`, trimmed and lowercased, must be token "/" token over RFC
406
+ * 9110's token characters.
407
+ */
408
+ export function mimeTypeOf(header) {
409
+ if (header === undefined || header === "")
410
+ return DEFAULT_CONTENT_MIME_TYPE;
411
+ const mediaType = header.split(";", 1)[0]?.trim().toLowerCase() ?? "";
412
+ return MEDIA_TYPE_SHAPE.test(mediaType) ? mediaType : DEFAULT_CONTENT_MIME_TYPE;
413
+ }
414
+ function headerValue(headers, name) {
415
+ const value = headers[name];
416
+ if (Array.isArray(value))
417
+ return value[0];
418
+ return value;
419
+ }
420
+ // redact strips a credential out of a failure that names the URL it was dialing. For a
421
+ // delivery fetch that query IS the credential, and on a refused redirect it is the
422
+ // credential of a URL the FIRST HOP chose, so a message carrying it leaks even when this
423
+ // module's own wording is already redacted.
424
+ //
425
+ // It returns a fresh Error rather than wrapping, which is the point: attaching the original
426
+ // as a `cause` would put the URL straight back into anything that walks the chain. So the
427
+ // CLASS has to be read off the original before it is discarded — see dialFailure.
428
+ function redact(cause) {
429
+ const message = cause instanceof Error ? cause.message : String(cause);
430
+ return new Error(message.replace(/\bhttps?:\/\/\S+/gi, "<url redacted>"));
431
+ }
432
+ // dialFailure is what a refusal of the DIAL answers, redacted.
433
+ //
434
+ // It carries NO reason. `reason` holds the peer's own refusal token, and a dial that was
435
+ // refused reached no peer — there is nothing for it to carry. This leg used to mint
436
+ // "ssrf_guard" here, which put the SDK's own verdict in a field documented across all
437
+ // three languages as the peer's; `not_canonical_wire_naming` says outright that it is the
438
+ // one place that happens. The shared corpus records `unreachable` for these rows, which is
439
+ // what Go and Python already answered.
440
+ //
441
+ // What that costs is worth naming: an address-pin refusal is a verdict about where this URL
442
+ // points and will refuse identically forever, while a momentary blip will not, and after
443
+ // this the two are indistinguishable to a caller. None of the three SDKs distinguishes
444
+ // them, so the answer is now the same everywhere rather than better in one — telling them
445
+ // apart is a change to the shared taxonomy, not to this function.
446
+ function dialFailure(op, cause) {
447
+ return new ForaCallError({
448
+ // A guard refusal is unreachable rather than not_sent, matching what Go answers for
449
+ // the same condition: there it surfaces through the RoundTripper, so the client
450
+ // reads it as a dial that did not happen. Both mean nothing was sent.
451
+ kind: "unreachable",
452
+ op,
453
+ cause: redact(cause),
454
+ });
455
+ }
456
+ // The delivery leg's dispatcher, guarded like every host chosen by another party and
457
+ // built once: an Agent holds a connection pool, so one per fetch would discard every
458
+ // kept-alive connection and re-dial for each item of a batch.
459
+ let contentDispatcher;
460
+ function sharedContentDispatcher() {
461
+ // The address guard, unless the deployment turned it off — the same SKIP_SSRF switch
462
+ // Go reads in NewGuardedTransport and Python in guarded_client. The scheme gate in
463
+ // vetDialable is separate and is NOT covered by that flag.
464
+ contentDispatcher ??= skipSSRF() ? new Agent() : new Agent({ connect: ssrfGuard() });
465
+ return contentDispatcher;
466
+ }
@@ -0,0 +1,79 @@
1
+ import type { ErrorDetail } from "../src/errordetail.ts";
2
+ /**
3
+ * CallErrorKind classifies why a client call did not produce an answer.
4
+ *
5
+ * - `refused` — a server that answered and said no, with a status and, usually, a typed
6
+ * reason.
7
+ * - `unreachable` — a server that did not answer: dial failure, timeout, or a redirect
8
+ * this SDK refused to follow.
9
+ * - `not_sent` — THIS SDK declining to send. The address failed the plain-hostname,
10
+ * same-host or dial-time guard, so nothing left the process and no signature was
11
+ * exposed.
12
+ * - `malformed` — a request that could not be built, or an answer that could not be read
13
+ * as the protocol defines it. Nothing was acted on.
14
+ * - `too_large` — a response body past the configured cap.
15
+ * - `not_signable` — a signature or proof that could not be produced, typically custody
16
+ * declining or timing out. Nothing left the process.
17
+ * - `unknown` — no classification.
18
+ */
19
+ export type CallErrorKind = "refused" | "unreachable" | "not_sent" | "malformed" | "too_large" | "not_signable" | "unknown";
20
+ /** What a ForaCallError carries beyond its message. */
21
+ export interface CallErrorInit {
22
+ kind: CallErrorKind;
23
+ /** The verb that failed, in the SDK's own words ("discover", "fetch content"). */
24
+ op: string;
25
+ /** HTTP status when the peer answered; absent otherwise. */
26
+ status?: number;
27
+ /** The peer's own refusal token when it sent one (a Connect code, an edge reason). */
28
+ reason?: string;
29
+ /** The typed protocol reason when there is one. */
30
+ detail?: ErrorDetail;
31
+ /** The developer message the peer put on its TYPED reason, when it sent one.
32
+ * Deliberately not filled from the transport envelope: an answer that did not
33
+ * come from a FORA service carries no message of its own, and the text a
34
+ * transport synthesizes for one is that transport's rather than the peer's. */
35
+ peerMessage?: string;
36
+ /** The underlying failure, kept so a caller can still reach a custody or resolver
37
+ * sentinel after the failure has been classified here. */
38
+ cause?: unknown;
39
+ }
40
+ /**
41
+ * ForaCallError is the client's typed failure. Every verb throws this and nothing else,
42
+ * so `instanceof ForaCallError` is not a coin flip on the very type callers are told to
43
+ * branch on — which is what happens when a method yields a typed error where it declines
44
+ * to send and a bare transport error where the peer refuses.
45
+ */
46
+ export declare class ForaCallError extends Error {
47
+ readonly kind: CallErrorKind;
48
+ readonly op: string;
49
+ readonly status: number | undefined;
50
+ readonly reason: string | undefined;
51
+ readonly detail: ErrorDetail | undefined;
52
+ /** The peer's own developer message, taken from its typed reason. Empty when the
53
+ * answer carried none.
54
+ *
55
+ * A field rather than something to recover from `message`'s rendering, because a
56
+ * reason rendered into prose cannot be read back out without parsing it. It sits
57
+ * BESIDE `reason` rather than in it: `reason` is the peer's machine token.
58
+ *
59
+ * NON-AUTHORITATIVE and UNBOUNDED — the contract says both of the field it comes
60
+ * from. Branch on `kind` or on the typed reason, never on this text, and bound it
61
+ * before rendering it to a log line or an agent. */
62
+ readonly peerMessage: string;
63
+ constructor(init: CallErrorInit);
64
+ /**
65
+ * The most specific machine-readable reason available: the peer's own token when it
66
+ * sent one, otherwise the failure class.
67
+ */
68
+ reasonOf(): string;
69
+ }
70
+ /** The refusal for an address that failed a routing check. Its own constructor because
71
+ * every such refusal must state which check declined and must never carry a status:
72
+ * nothing was sent, so there is nothing to report a status for. */
73
+ export declare function notSent(op: string, cause: unknown): ForaCallError;
74
+ /** The refusal for a request that could not be assembled, or an answer that could not be
75
+ * read as the protocol defines it. */
76
+ export declare function malformed(op: string, cause: unknown): ForaCallError;
77
+ /** The Connect code a non-envelope answer carries, derived from its status. */
78
+ export declare function connectCodeFromStatus(status: number): string;
79
+ export declare function kindOfConnectCode(code: string): CallErrorKind;