@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.
- package/LICENSE +201 -0
- package/README.md +37 -0
- package/gen/ts/vocab/functiontokens.js +78 -0
- package/gen/ts/vocab/functiontokens.ts +84 -0
- package/gen/ts/vocab/geographytokens.js +30 -0
- package/gen/ts/vocab/geographytokens.ts +37 -0
- package/gen/ts/vocab/pricingunits.js +56 -0
- package/gen/ts/vocab/pricingunits.ts +63 -0
- package/gen/ts/vocab/quotametrics.js +40 -0
- package/gen/ts/vocab/quotametrics.ts +47 -0
- package/gen/ts/vocab/usertypes.js +40 -0
- package/gen/ts/vocab/usertypes.ts +46 -0
- package/gen/ts/wire/base.js +245 -0
- package/gen/ts/wire/base.ts +255 -0
- package/gen/ts/wire/names.js +41 -0
- package/gen/ts/wire/names.ts +42 -0
- package/gen/ts/wire/schemas.js +111 -0
- package/gen/ts/wire/schemas.ts +216 -0
- package/package.json +164 -0
- package/sdk/ts/client/content.d.ts +74 -0
- package/sdk/ts/client/content.js +466 -0
- package/sdk/ts/client/errors.d.ts +79 -0
- package/sdk/ts/client/errors.js +148 -0
- package/sdk/ts/client/index.d.ts +241 -0
- package/sdk/ts/client/index.js +740 -0
- package/sdk/ts/client/route.d.ts +41 -0
- package/sdk/ts/client/route.js +107 -0
- package/sdk/ts/client/send.d.ts +64 -0
- package/sdk/ts/client/send.js +142 -0
- package/sdk/ts/client/transport.d.ts +187 -0
- package/sdk/ts/client/transport.js +381 -0
- package/sdk/ts/core/multisig-parse.d.ts +47 -0
- package/sdk/ts/core/multisig-parse.js +234 -0
- package/sdk/ts/core/sign-request.d.ts +83 -0
- package/sdk/ts/core/sign-request.js +146 -0
- package/sdk/ts/core/sign.d.ts +31 -0
- package/sdk/ts/core/sign.js +82 -0
- package/sdk/ts/core/signing-transport.d.ts +75 -0
- package/sdk/ts/core/signing-transport.js +172 -0
- package/sdk/ts/core/verifier.d.ts +177 -0
- package/sdk/ts/core/verifier.js +269 -0
- package/sdk/ts/core/verify-multisig-request.d.ts +72 -0
- package/sdk/ts/core/verify-multisig-request.js +112 -0
- package/sdk/ts/core/verify-request.d.ts +181 -0
- package/sdk/ts/core/verify-request.js +284 -0
- package/sdk/ts/core/window.d.ts +22 -0
- package/sdk/ts/core/window.js +43 -0
- package/sdk/ts/core/wire-canon.d.ts +20 -0
- package/sdk/ts/core/wire-canon.js +174 -0
- package/sdk/ts/hono/middleware.d.ts +38 -0
- package/sdk/ts/hono/middleware.js +64 -0
- package/sdk/ts/resolvers/errors.d.ts +130 -0
- package/sdk/ts/resolvers/errors.js +150 -0
- package/sdk/ts/resolvers/http.d.ts +98 -0
- package/sdk/ts/resolvers/http.js +274 -0
- package/sdk/ts/resolvers/index.d.ts +9 -0
- package/sdk/ts/resolvers/index.js +15 -0
- package/sdk/ts/resolvers/jwks.d.ts +2 -0
- package/sdk/ts/resolvers/jwks.js +43 -0
- package/sdk/ts/resolvers/offer-key-cache.d.ts +68 -0
- package/sdk/ts/resolvers/offer-key-cache.js +152 -0
- package/sdk/ts/resolvers/registration-requirements.d.ts +47 -0
- package/sdk/ts/resolvers/registration-requirements.js +220 -0
- package/sdk/ts/resolvers/ssrf.d.ts +28 -0
- package/sdk/ts/resolvers/ssrf.js +235 -0
- package/sdk/ts/resolvers/static.d.ts +7 -0
- package/sdk/ts/resolvers/static.js +16 -0
- package/sdk/ts/resolvers/wba.d.ts +134 -0
- package/sdk/ts/resolvers/wba.js +506 -0
- package/sdk/ts/resolvers/wellknown.d.ts +28 -0
- package/sdk/ts/resolvers/wellknown.js +169 -0
- package/sdk/ts/src/acceptance.d.ts +46 -0
- package/sdk/ts/src/acceptance.js +162 -0
- package/sdk/ts/src/base64url.d.ts +19 -0
- package/sdk/ts/src/base64url.js +55 -0
- package/sdk/ts/src/crossfield.d.ts +28 -0
- package/sdk/ts/src/crossfield.js +215 -0
- package/sdk/ts/src/endpoint-rule.d.ts +30 -0
- package/sdk/ts/src/endpoint-rule.js +63 -0
- package/sdk/ts/src/errordetail.d.ts +115 -0
- package/sdk/ts/src/errordetail.js +256 -0
- package/sdk/ts/src/hashurl.d.ts +5 -0
- package/sdk/ts/src/hashurl.js +15 -0
- package/sdk/ts/src/host-ref.d.ts +70 -0
- package/sdk/ts/src/host-ref.js +320 -0
- package/sdk/ts/src/hosts.d.ts +138 -0
- package/sdk/ts/src/hosts.js +214 -0
- package/sdk/ts/src/idempotency.d.ts +11 -0
- package/sdk/ts/src/idempotency.js +27 -0
- package/sdk/ts/src/jsondepth.d.ts +27 -0
- package/sdk/ts/src/jsondepth.js +77 -0
- package/sdk/ts/src/licenseterm.d.ts +113 -0
- package/sdk/ts/src/licenseterm.js +433 -0
- package/sdk/ts/src/money.d.ts +21 -0
- package/sdk/ts/src/money.js +60 -0
- package/sdk/ts/src/offer-sign.d.ts +11 -0
- package/sdk/ts/src/offer-sign.js +31 -0
- package/sdk/ts/src/opaque-url.d.ts +3 -0
- package/sdk/ts/src/opaque-url.js +20 -0
- package/sdk/ts/src/pop.d.ts +51 -0
- package/sdk/ts/src/pop.js +166 -0
- package/sdk/ts/src/regschema.d.ts +229 -0
- package/sdk/ts/src/regschema.js +1315 -0
- package/sdk/ts/src/scopes.d.ts +20 -0
- package/sdk/ts/src/scopes.js +45 -0
- package/sdk/ts/src/signurl.d.ts +23 -0
- package/sdk/ts/src/signurl.js +107 -0
- package/sdk/ts/src/thumbprint.d.ts +14 -0
- package/sdk/ts/src/thumbprint.js +24 -0
- package/sdk/ts/src/verify.d.ts +49 -0
- package/sdk/ts/src/verify.js +109 -0
- package/sdk/ts/src/wire-names.d.ts +1 -0
- package/sdk/ts/src/wire-names.js +16 -0
- package/sdk/ts/src/wire.d.ts +68 -0
- 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;
|