@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,274 @@
|
|
|
1
|
+
// The one HTTP transport seam the fetching resolvers share. Transport-neutral
|
|
2
|
+
// per the SDK dependency policy (the resolvers accept an injected fetch-compatible
|
|
3
|
+
// callable) but the DEFAULT transport now runs on a maintained HTTP client
|
|
4
|
+
// (undici) instead of a hand-rolled node:http request. undici owns the response
|
|
5
|
+
// state machine — status, redirects, 1xx, decompression — so this module owns
|
|
6
|
+
// only the SSRF guard (an injectable connection-level connector) and the
|
|
7
|
+
// fail-closed status→body taxonomy. Integration tests / on-prem deployments that
|
|
8
|
+
// must reach a private origin inject their own FetchLike (the escape hatch).
|
|
9
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
10
|
+
import { Agent, buildConnector, interceptors, request as undiciRequest, } from "undici";
|
|
11
|
+
import { DirectoryUnavailable } from "./errors.js";
|
|
12
|
+
import { allowedScheme, blockedAddress, MAX_REDIRECTS } from "./ssrf.js";
|
|
13
|
+
/** Bounds the guarded default transport's GET so a slow origin cannot pin a
|
|
14
|
+
* Resolve call or the poller (Go: defaultWBAHTTPTimeout). */
|
|
15
|
+
const DEFAULT_HTTP_TIMEOUT_MS = 10_000;
|
|
16
|
+
/** Well-known documents are small; bound the body read (Go: maxDocBytes). */
|
|
17
|
+
const MAX_DOC_BYTES = 1 << 20; // 1 MiB
|
|
18
|
+
/** An SSRF error surfaced when the guarded transport refuses to dial a target.
|
|
19
|
+
* fetchStrict/fetchSoft see it as an ordinary transport failure (fail-closed
|
|
20
|
+
* DirectoryUnavailable / best-effort undefined), so the guard never resolves a
|
|
21
|
+
* blocked host as a valid — merely empty — directory. */
|
|
22
|
+
export class SsrfBlockedError extends Error {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "SsrfBlockedError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** The GENERIC dial-refusal the guard raises for EVERY refuse reason — an
|
|
29
|
+
* unresolvable host, an empty resolution, or a resolved-reserved address all yield
|
|
30
|
+
* the SAME message. It names only the caller-supplied host (already known to the
|
|
31
|
+
* caller), never the resolved IP, and does not distinguish an NXDOMAIN from a
|
|
32
|
+
* resolved-private answer, so it cannot serve as a pre-auth DNS oracle against
|
|
33
|
+
* internal networks. Identical wording across the three SDKs. */
|
|
34
|
+
function ssrfRefusal(host) {
|
|
35
|
+
return new SsrfBlockedError(`SSRF guard: refusing to dial ${host}`);
|
|
36
|
+
}
|
|
37
|
+
/** An SSRF-guarded undici connector, injectable into any undici Dispatcher.
|
|
38
|
+
*
|
|
39
|
+
* DX: `new Agent({ connect: ssrfGuard() })`. The guard runs at the CONNECTION
|
|
40
|
+
* (dial) seam — the only place the DNS-REBINDING window is actually closed: it
|
|
41
|
+
* resolves the host, checks EVERY resolved address against blockedAddress, and
|
|
42
|
+
* pins the dial to a checked IP literal (undici does not re-resolve it), while
|
|
43
|
+
* the TLS `servername` keeps the original hostname so cert/SNI validation is
|
|
44
|
+
* unaffected. Because undici re-dials every followed redirect through the same
|
|
45
|
+
* connector, each redirect hop is re-vetted too (consistent with the Go and
|
|
46
|
+
* Python guards); a redirect into a non-http(s) scheme is a WHATWG-fetch network
|
|
47
|
+
* error, so the scheme allowlist is deny-by-default without a redirect handler. */
|
|
48
|
+
export function ssrfGuard() {
|
|
49
|
+
const base = buildConnector({});
|
|
50
|
+
return (opts, cb) => {
|
|
51
|
+
const originalHostname = opts.hostname;
|
|
52
|
+
dnsLookup(originalHostname, { all: true })
|
|
53
|
+
.then((records) => {
|
|
54
|
+
// Fail closed on an empty resolution OR any reserved address in the set (the
|
|
55
|
+
// multi-address any-reserved rule: a MIXED public/reserved answer is refused
|
|
56
|
+
// outright, so a rebinding/round-robin trick cannot land a later connect on
|
|
57
|
+
// the reserved member). Every refuse path raises the SAME generic error.
|
|
58
|
+
if (records.length === 0 ||
|
|
59
|
+
records.some(({ address }) => blockedAddress(address))) {
|
|
60
|
+
cb(ssrfRefusal(originalHostname), null);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Every address passed; pin to the first (all public) checked IP literal
|
|
64
|
+
// — no re-resolution at connect — and keep servername = original host.
|
|
65
|
+
base({
|
|
66
|
+
...opts,
|
|
67
|
+
hostname: records[0]?.address ?? originalHostname,
|
|
68
|
+
servername: opts.servername || originalHostname,
|
|
69
|
+
}, cb);
|
|
70
|
+
})
|
|
71
|
+
.catch(() => {
|
|
72
|
+
cb(ssrfRefusal(originalHostname), null);
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** The single guarded Dispatcher backing the default guarded transport. The SSRF
|
|
77
|
+
* connector vets + pins every dial (initial and each followed redirect hop); the
|
|
78
|
+
* composed redirect interceptor bounds the chain to the shared MAX_REDIRECTS cap so
|
|
79
|
+
* undici does not inherit its ~20-hop default. Beyond the cap the interceptor stops
|
|
80
|
+
* following and surfaces the 3xx as an ordinary non-2xx (fail-closed at fetchStrict). */
|
|
81
|
+
const guardedAgent = new Agent({ connect: ssrfGuard() }).compose(interceptors.redirect({ maxRedirections: MAX_REDIRECTS }));
|
|
82
|
+
/** Reads an undici response body as text, bounded to MAX_DOC_BYTES. Iterates the
|
|
83
|
+
* body stream (async-iterable in Node) so a hostile origin cannot force an
|
|
84
|
+
* unbounded read; breaking the loop cancels the stream once the cap is hit. */
|
|
85
|
+
async function readBounded(body) {
|
|
86
|
+
if (body === null)
|
|
87
|
+
return "";
|
|
88
|
+
const chunks = [];
|
|
89
|
+
let total = 0;
|
|
90
|
+
for await (const chunk of body) {
|
|
91
|
+
chunks.push(chunk);
|
|
92
|
+
total += chunk.length;
|
|
93
|
+
if (total >= MAX_DOC_BYTES)
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
|
|
97
|
+
return buf.subarray(0, MAX_DOC_BYTES).toString("utf8");
|
|
98
|
+
}
|
|
99
|
+
/** GET `url` through `dispatcher` (an undici Agent carrying the SSRF connector
|
|
100
|
+
* and the redirect-cap interceptor), gating the initial scheme with `allowScheme`
|
|
101
|
+
* and bounding the body to MAX_DOC_BYTES. undici owns status/redirect/1xx/decompress
|
|
102
|
+
* and the interceptor bounds the redirect chain, so this owns only the scheme gate
|
|
103
|
+
* and the body cap. A blocked target surfaces as the precise SsrfBlockedError (the
|
|
104
|
+
* connector rejects directly or via `err.cause`); non-guard errors propagate. The
|
|
105
|
+
* one request path both guarded faces share, so their wiring cannot drift. */
|
|
106
|
+
async function requestBounded(url, dispatcher, allowScheme) {
|
|
107
|
+
let parsed;
|
|
108
|
+
try {
|
|
109
|
+
parsed = new URL(url);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
throw new SsrfBlockedError(`SSRF guard: unparseable url ${url}: ${String(err)}`);
|
|
113
|
+
}
|
|
114
|
+
// Gate the scheme BEFORE any dial. URL.protocol carries a trailing colon ("https:").
|
|
115
|
+
const scheme = parsed.protocol.replace(/:$/, "");
|
|
116
|
+
if (!allowScheme(scheme)) {
|
|
117
|
+
throw new SsrfBlockedError(`SSRF guard: refusing disallowed scheme ${parsed.protocol}`);
|
|
118
|
+
}
|
|
119
|
+
let resp;
|
|
120
|
+
try {
|
|
121
|
+
resp = await undiciRequest(url, {
|
|
122
|
+
dispatcher,
|
|
123
|
+
signal: AbortSignal.timeout(DEFAULT_HTTP_TIMEOUT_MS),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
if (err instanceof SsrfBlockedError)
|
|
128
|
+
throw err;
|
|
129
|
+
const cause = err.cause;
|
|
130
|
+
if (cause instanceof SsrfBlockedError)
|
|
131
|
+
throw cause;
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
const body = await readBounded(resp.body);
|
|
135
|
+
return { status: resp.statusCode, text: () => Promise.resolve(body) };
|
|
136
|
+
}
|
|
137
|
+
/** The SSRF-guarded default transport. The directory host is derived from a
|
|
138
|
+
* caller-supplied Signature-Agent and the fetch runs BEFORE the ed25519 check,
|
|
139
|
+
* so an unguarded default would be a pre-auth SSRF lever. Runs on undici through
|
|
140
|
+
* the guarded connector (see ssrfGuard): the initial URL's scheme is vetted
|
|
141
|
+
* deny-by-default, every dial (initial + each redirect hop) is address-checked and
|
|
142
|
+
* pinned, the redirect chain is bounded to MAX_REDIRECTS, and undici owns
|
|
143
|
+
* status/redirect/1xx so a non-2xx is an ordinary response, never a crash. */
|
|
144
|
+
export const guardedFetch = (url) => requestBounded(url, guardedAgent, allowedScheme);
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// The ONE env-driven, best-effort guarded fetch factory.
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
/** Read a boolean env flag: true iff the value is "true" (any case) or "1". Every
|
|
149
|
+
* other value (including unset and "0") is false. */
|
|
150
|
+
function envFlag(name) {
|
|
151
|
+
return ["true", "1"].includes((process.env[name] ?? "").toLowerCase());
|
|
152
|
+
}
|
|
153
|
+
/** Whether the dial-time address guard is disabled (SKIP_SSRF). Default: off.
|
|
154
|
+
*
|
|
155
|
+
* Exported because the client tier builds its own dispatchers and has to honour the same
|
|
156
|
+
* deployment flag this tier does — Go reads it in NewGuardedTransport and Python in
|
|
157
|
+
* guarded_client, so a TypeScript client that ignored it would be the one place the
|
|
158
|
+
* documented opt-out did nothing. */
|
|
159
|
+
export function skipSSRF() {
|
|
160
|
+
return envFlag("SKIP_SSRF");
|
|
161
|
+
}
|
|
162
|
+
/** Whether plaintext http is permitted (ALLOW_INSECURE); else https-only. Default: off. */
|
|
163
|
+
function allowInsecure() {
|
|
164
|
+
return envFlag("ALLOW_INSECURE");
|
|
165
|
+
}
|
|
166
|
+
/** The two-flag scheme decision: https always, http only under ALLOW_INSECURE,
|
|
167
|
+
* everything else denied (a scheme denylist is unwinnable — ftp, telnet, gopher,
|
|
168
|
+
* file, data, …). Case-insensitive. */
|
|
169
|
+
export function schemeGuardAllows(scheme) {
|
|
170
|
+
const s = scheme.toLowerCase();
|
|
171
|
+
if (s === "https")
|
|
172
|
+
return true;
|
|
173
|
+
return s === "http" && allowInsecure();
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* requireScheme refuses a URL this SDK will not dial, BEFORE any dial happens.
|
|
177
|
+
*
|
|
178
|
+
* The connector returned by ssrfGuard is an ADDRESS pin — it decides what a hostname is
|
|
179
|
+
* allowed to resolve to, and it never sees the scheme, because by then the URL has already
|
|
180
|
+
* been reduced to a host and a port. So a dispatcher built from it alone will happily carry
|
|
181
|
+
* an RFC 9421 signature, or a proof of possession, over plaintext http. The scheme is a
|
|
182
|
+
* separate decision and needs a separate gate: Go states it in schemeGuardRoundTripper and
|
|
183
|
+
* Python in _SchemeGuardTransport, both wrapping the transport so it applies to whatever
|
|
184
|
+
* base a caller injected. This is the same gate for the callers that dial undici directly.
|
|
185
|
+
*
|
|
186
|
+
* Raising SsrfBlockedError rather than a typed client failure is deliberate: the client
|
|
187
|
+
* tier already classifies an unrecognised dial failure the way Go classifies this one,
|
|
188
|
+
* which reaches it through the RoundTripper for the same reason.
|
|
189
|
+
*/
|
|
190
|
+
export function requireScheme(url) {
|
|
191
|
+
let parsed;
|
|
192
|
+
try {
|
|
193
|
+
parsed = new URL(url);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
throw new SsrfBlockedError(`SSRF guard: unparseable url: ${String(err)}`);
|
|
197
|
+
}
|
|
198
|
+
// URL.protocol carries a trailing colon ("https:").
|
|
199
|
+
if (!schemeGuardAllows(parsed.protocol.replace(/:$/, ""))) {
|
|
200
|
+
throw new SsrfBlockedError(`SSRF guard: refusing disallowed scheme ${parsed.protocol}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** The ONE public env-driven best-effort guarded fetch factory — every consumer's
|
|
204
|
+
* fetch for any third-party-influenceable request. Two orthogonal env flags drive
|
|
205
|
+
* it: SKIP_SSRF toggles the dial-time address guard (default: on), ALLOW_INSECURE
|
|
206
|
+
* toggles the scheme guard (default: https-only). There is no deployment-stack
|
|
207
|
+
* allow-list and no config error. Both paths use a per-factory undici Agent that
|
|
208
|
+
* ignores HTTP(S)_PROXY env, so a proxied CONNECT cannot tunnel a private target
|
|
209
|
+
* past the (guarded) dial guard, and both carry the same request timeout. Returns
|
|
210
|
+
* a FetchLike closing over one dispatcher. */
|
|
211
|
+
export function guardedFetchFromEnv() {
|
|
212
|
+
// One dispatcher per factory: the SSRF connector (unless SKIP_SSRF) plus the
|
|
213
|
+
// redirect-cap interceptor, so a proxied CONNECT cannot tunnel past the (guarded)
|
|
214
|
+
// dial guard and the redirect chain is bounded to the shared MAX_REDIRECTS cap.
|
|
215
|
+
const base = skipSSRF() ? new Agent() : new Agent({ connect: ssrfGuard() });
|
|
216
|
+
const dispatcher = base.compose(interceptors.redirect({ maxRedirections: MAX_REDIRECTS }));
|
|
217
|
+
return (url) => requestBounded(url, dispatcher, schemeGuardAllows);
|
|
218
|
+
}
|
|
219
|
+
/** Default transport for a resolver whose URL is a FIXED, operator-chosen address
|
|
220
|
+
* (the well-known JWKS): a plain fetch, NOT SSRF-guarded. An on-prem JWKS may
|
|
221
|
+
* legitimately be private, and the operator rather than an attacker chose it.
|
|
222
|
+
*
|
|
223
|
+
* Which default a resolver takes follows its URL's PROVENANCE, and that is the
|
|
224
|
+
* whole rule — the Go oracle states it in the options struct these ports mirror.
|
|
225
|
+
* A fixed operator-chosen URL takes this transport. A REQUEST-DERIVED host — the
|
|
226
|
+
* WBA directory named by a Signature-Agent header, an Exchange domain read off an
|
|
227
|
+
* offer or a registration — takes `guardedFetchFromEnv`, because the party
|
|
228
|
+
* choosing the address is not the party running the process.
|
|
229
|
+
*
|
|
230
|
+
* The endpoint resolver in this package is request-derived and still defaults
|
|
231
|
+
* here, which is a known gap being closed separately; it is not the rule. Do not
|
|
232
|
+
* reach for this transport for a new resolver without first asking where its URL
|
|
233
|
+
* comes from. */
|
|
234
|
+
export const defaultFetch = async (url) => {
|
|
235
|
+
const r = await fetch(url);
|
|
236
|
+
// Bound the body read even on the unguarded path: a misconfigured / hostile
|
|
237
|
+
// well-known origin cannot force an unbounded read into the JSON decoder. The
|
|
238
|
+
// WHATWG Response body is async-iterable in Node, so it reuses readBounded (the
|
|
239
|
+
// same 1 MiB cap as the guarded transport and the Go/Python well-known paths).
|
|
240
|
+
const body = await readBounded(r.body);
|
|
241
|
+
return { status: r.status, text: () => Promise.resolve(body) };
|
|
242
|
+
};
|
|
243
|
+
/** GET `url` and return the body text. A transport failure or a non-200 status
|
|
244
|
+
* throws DirectoryUnavailable (fail-closed halt) — the taxonomy a composite
|
|
245
|
+
* relies on to distinguish an outage from an unknown key. A blocked SSRF target
|
|
246
|
+
* is a transport failure and surfaces the same way (never a valid empty doc). */
|
|
247
|
+
export async function fetchStrict(fetchFn, url) {
|
|
248
|
+
let resp;
|
|
249
|
+
try {
|
|
250
|
+
resp = await fetchFn(url);
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
throw new DirectoryUnavailable(`fetch ${url}`, { cause: err });
|
|
254
|
+
}
|
|
255
|
+
if (resp.status !== 200) {
|
|
256
|
+
throw new DirectoryUnavailable(`status ${resp.status} for ${url}`);
|
|
257
|
+
}
|
|
258
|
+
return resp.text();
|
|
259
|
+
}
|
|
260
|
+
/** Best-effort GET: returns the body text on 200, or `undefined` on any
|
|
261
|
+
* transport/status failure. The revocation refresh uses this so a fetch blip
|
|
262
|
+
* leaves the prior snapshot in place (Go: best-effort refresh) rather than
|
|
263
|
+
* propagating — a stale-but-present snapshot is safer than dropping revocations. */
|
|
264
|
+
export async function fetchSoft(fetchFn, url) {
|
|
265
|
+
try {
|
|
266
|
+
const resp = await fetchFn(url);
|
|
267
|
+
if (resp.status !== 200)
|
|
268
|
+
return undefined;
|
|
269
|
+
return await resp.text();
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { DirectoryUnavailable, EndpointRefused, ExchangeNotPermitted, KeyExpired, KeyRevoked, ManifestNotExchange, ManifestUnusable, ManifestVersionRefused, NoEndpoint, ResolverError, RevocationUnevaluated, UnknownKey, } from "./errors.ts";
|
|
2
|
+
export type { FetchLike, FetchResponse } from "./http.ts";
|
|
3
|
+
export { guardedFetchFromEnv, SsrfBlockedError, ssrfGuard } from "./http.ts";
|
|
4
|
+
export { type CachedOfferKeyResolver, type CachedOfferKeyResolverOptions, clampOfferKeyExpiry, createCachedOfferKeyResolver, createWBAOfferDirectoryFetch, type OfferDirectoryFetch, } from "./offer-key-cache.ts";
|
|
5
|
+
export { createWellKnownRequirementsReader, type RegistrationRequirements, type WellKnownRequirementsOptions, type WellKnownRequirementsReader, } from "./registration-requirements.ts";
|
|
6
|
+
export { allowedScheme, blockedAddress } from "./ssrf.ts";
|
|
7
|
+
export { createStaticKeyResolver, type StaticKeyResolver } from "./static.ts";
|
|
8
|
+
export { activeEd25519Key, activeEd25519KeyScreened, activeEd25519KeyWithExpiry, activeEd25519KeyWithExpiryScreened, createWBAKeyResolver, WBA_DIRECTORY_PATH, type WBAKeyResolver, type WBAKeyResolverOptions, wbaDirectoryURL, } from "./wba.ts";
|
|
9
|
+
export { type EndpointOptions, createWellKnownEndpointResolver, createWellKnownKeyResolver, type WellKnownEndpointResolver, type WellKnownKeyResolver, type WellKnownOptions, } from "./wellknown.ts";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Public surface of the FORA SDK resolver faces (ADR-020 §4). These are the FIRST
|
|
2
|
+
// IO in the TS SDK, so they live OUTSIDE the IO-free core/src tree (exported via
|
|
3
|
+
// package.json `exports`) to keep the transport-neutrality invariant on core
|
|
4
|
+
// green. Four faces port the Go oracle: a static named map, a well-known JWKS
|
|
5
|
+
// key resolver, a host-keyed well-known endpoint resolver, and the WBA
|
|
6
|
+
// identity-directory resolver + revocation poller. The typed error classes
|
|
7
|
+
// preserve the oracle's errors.Is-DISTINCT fail-closed taxonomy.
|
|
8
|
+
export { DirectoryUnavailable, EndpointRefused, ExchangeNotPermitted, KeyExpired, KeyRevoked, ManifestNotExchange, ManifestUnusable, ManifestVersionRefused, NoEndpoint, ResolverError, RevocationUnevaluated, UnknownKey, } from "./errors.js";
|
|
9
|
+
export { guardedFetchFromEnv, SsrfBlockedError, ssrfGuard } from "./http.js";
|
|
10
|
+
export { clampOfferKeyExpiry, createCachedOfferKeyResolver, createWBAOfferDirectoryFetch, } from "./offer-key-cache.js";
|
|
11
|
+
export { createWellKnownRequirementsReader, } from "./registration-requirements.js";
|
|
12
|
+
export { allowedScheme, blockedAddress } from "./ssrf.js";
|
|
13
|
+
export { createStaticKeyResolver } from "./static.js";
|
|
14
|
+
export { activeEd25519Key, activeEd25519KeyScreened, activeEd25519KeyWithExpiry, activeEd25519KeyWithExpiryScreened, createWBAKeyResolver, WBA_DIRECTORY_PATH, wbaDirectoryURL, } from "./wba.js";
|
|
15
|
+
export { createWellKnownEndpointResolver, createWellKnownKeyResolver, } from "./wellknown.js";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Ad-hoc JWKS decode for the well-known KEY document. The publisher's
|
|
2
|
+
// fora.json key set is the kid-carrying RFC 7517 subset
|
|
3
|
+
// `{"keys":[{"kid","kty":"OKP","crv":"Ed25519","x":"<base64url>"}]}` — NOT the
|
|
4
|
+
// gen JsonWebKey/WellKnownManifest schemas, which carry no `kid` and no JWK-set
|
|
5
|
+
// (decoding through them would resolve ZERO keys). The gen schemas are reserved
|
|
6
|
+
// for the WBA path (thumbprint-keyed, needs no kid). base64url `x` decode reuses
|
|
7
|
+
// the byte-parity-pinned primitive.
|
|
8
|
+
//
|
|
9
|
+
// Extraction is SKIP-NOT-FAIL (mirroring go-jose ed25519KeysFromJWKS): a key
|
|
10
|
+
// with an empty/missing kid, a non-OKP/Ed25519 type, or a wrong-length `x` is
|
|
11
|
+
// skipped, survivors are keyed by kid, and one bad key never fails the whole set.
|
|
12
|
+
import { decodeBase64UrlStrict } from "../src/base64url.js";
|
|
13
|
+
const ED25519_PUBLIC_KEY_BYTES = 32;
|
|
14
|
+
/** Extract the Ed25519 public keys (keyed by kid) from a parsed JWKS doc. */
|
|
15
|
+
export function ed25519KeysFromJwks(doc) {
|
|
16
|
+
const out = new Map();
|
|
17
|
+
const keys = doc.keys;
|
|
18
|
+
if (!Array.isArray(keys))
|
|
19
|
+
return out;
|
|
20
|
+
for (const entry of keys) {
|
|
21
|
+
const extracted = extractEd25519(entry);
|
|
22
|
+
if (extracted)
|
|
23
|
+
out.set(extracted.kid, extracted.pub);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
function extractEd25519(entry) {
|
|
28
|
+
if (typeof entry !== "object" || entry === null)
|
|
29
|
+
return undefined;
|
|
30
|
+
const e = entry;
|
|
31
|
+
if (typeof e.kid !== "string" || e.kid === "")
|
|
32
|
+
return undefined;
|
|
33
|
+
if (e.kty !== "OKP" || e.crv !== "Ed25519")
|
|
34
|
+
return undefined;
|
|
35
|
+
if (typeof e.x !== "string")
|
|
36
|
+
return undefined;
|
|
37
|
+
// JWK OKP `x` is UNPADDED base64url (RFC 8037); reject padding / the standard
|
|
38
|
+
// alphabet to match Go's go-jose JWKS parse (RawURLEncoding).
|
|
39
|
+
const raw = decodeBase64UrlStrict(e.x);
|
|
40
|
+
if (!raw || raw.length !== ED25519_PUBLIC_KEY_BYTES)
|
|
41
|
+
return undefined;
|
|
42
|
+
return { kid: e.kid, pub: raw };
|
|
43
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { WBAFileSchema } from "../../../gen/ts/wire/schemas.ts";
|
|
2
|
+
import type { OfferKeyResolver } from "../core/verifier.ts";
|
|
3
|
+
import { type FetchLike } from "./http.ts";
|
|
4
|
+
/** A parsed WBA identity directory — the shape the injected fetch seam returns. */
|
|
5
|
+
type WBAFile = ReturnType<typeof WBAFileSchema.parse>;
|
|
6
|
+
/**
|
|
7
|
+
* OfferDirectoryFetch resolves one exchange domain to its WBA directory, or
|
|
8
|
+
* `undefined` when the directory is unresolvable (unreachable, malformed, blocked).
|
|
9
|
+
* The injected IO seam of the cache: a test injects a directory table with no
|
|
10
|
+
* network, an app wraps its shared well-known fetch. The seam SHOULD contain its
|
|
11
|
+
* own failures as `undefined` rather than throw — but resolve() defends against a
|
|
12
|
+
* throwing seam anyway (Core Invariant), so a hand-rolled fetcher that raises still
|
|
13
|
+
* fails closed. Mirrors Python's `DirectoryFetch` None-absence contract.
|
|
14
|
+
*/
|
|
15
|
+
export type OfferDirectoryFetch = (domain: string) => Promise<WBAFile | undefined>;
|
|
16
|
+
/** Wires a {@link createCachedOfferKeyResolver}. `fetch` is REQUIRED. */
|
|
17
|
+
export interface CachedOfferKeyResolverOptions {
|
|
18
|
+
/** Resolves a domain to its WBA directory. REQUIRED — the factory throws a
|
|
19
|
+
* TypeError at construction when it is missing (a resolver with no directory
|
|
20
|
+
* source can never resolve anything). */
|
|
21
|
+
fetch: OfferDirectoryFetch;
|
|
22
|
+
/** Per-domain cache TTL in ms; ≤0/undefined → {@link DEFAULT_TTL_MS}. The stored
|
|
23
|
+
* entry's expiry is clamped to min(now+ttlMs, not_after). */
|
|
24
|
+
ttlMs?: number;
|
|
25
|
+
/** The cache-freshness + selection clock; undefined → Date.now. Tests inject. */
|
|
26
|
+
now?: () => number;
|
|
27
|
+
/** Screens a candidate key by its RFC 7638 thumbprint — a revoked key is skipped
|
|
28
|
+
* during selection so a window-active-but-revoked key is never served. undefined
|
|
29
|
+
* screens nothing; inject a revoked-set predicate on any verification path. A
|
|
30
|
+
* throwing predicate fails closed (resolve → undefined), never propagates. */
|
|
31
|
+
revoked?: (thumbprint: string) => boolean;
|
|
32
|
+
}
|
|
33
|
+
/** The public face: the OfferKeyResolver core.Verifier resolves offer keys through. */
|
|
34
|
+
export type CachedOfferKeyResolver = OfferKeyResolver;
|
|
35
|
+
/**
|
|
36
|
+
* clampOfferKeyExpiry bounds a cache entry's expiry to the key's validity window:
|
|
37
|
+
* the entry expires at min(nowMs+ttlMs, notAfterMs), so a key is never served past
|
|
38
|
+
* its not_after even within the TTL. A pure helper (all epoch-ms) so the
|
|
39
|
+
* tri-language clamp corpus replays the SAME arithmetic the resolver runs, not a
|
|
40
|
+
* re-inlined copy that could silently drift.
|
|
41
|
+
*/
|
|
42
|
+
export declare function clampOfferKeyExpiry(nowMs: number, ttlMs: number, notAfterMs: number): number;
|
|
43
|
+
/**
|
|
44
|
+
* createCachedOfferKeyResolver builds a domain-keyed offer-key cache implementing
|
|
45
|
+
* OfferKeyResolver. `newX` for family consistency with the sibling resolver
|
|
46
|
+
* factories (createWBAKeyResolver, createWellKnownKeyResolver, …). Throws TypeError when
|
|
47
|
+
* opts.fetch is missing; every other failure fails closed at resolve() time.
|
|
48
|
+
*/
|
|
49
|
+
export declare function createCachedOfferKeyResolver(opts: CachedOfferKeyResolverOptions): CachedOfferKeyResolver;
|
|
50
|
+
/**
|
|
51
|
+
* createWBAOfferDirectoryFetch returns the default {@link OfferDirectoryFetch}: it
|
|
52
|
+
* GETs {scheme}://{domain}[:{port}]{WBA_DIRECTORY_PATH} (built by the shared
|
|
53
|
+
* wbaDirectoryURL) and parses the body as a WBAFile, returning `undefined` on ANY
|
|
54
|
+
* transport/status/decode failure so
|
|
55
|
+
* the default fetcher itself upholds the undefined-not-throw seam contract.
|
|
56
|
+
*
|
|
57
|
+
* The default transport is SSRF-guarded (guardedFetchFromEnv): the exchange domain
|
|
58
|
+
* is signature-covered but attacker-influenceable and the fetch runs before the
|
|
59
|
+
* offer signature is checked, so an unguarded default would be a pre-auth SSRF
|
|
60
|
+
* lever (mirrors Go NewWBADirectoryFetcher). Tests inject a loopback fetch; apps
|
|
61
|
+
* may inject a fetch wrapping their shared well-known client.
|
|
62
|
+
*/
|
|
63
|
+
export declare function createWBAOfferDirectoryFetch(opts?: {
|
|
64
|
+
fetch?: FetchLike;
|
|
65
|
+
scheme?: string;
|
|
66
|
+
port?: string;
|
|
67
|
+
}): OfferDirectoryFetch;
|
|
68
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// CachedOfferKeyResolver — the domain-keyed offer-signing-key cache that
|
|
2
|
+
// consolidates the Broker's offerkeys.Resolver and the MCP shim's
|
|
3
|
+
// ExchangeOfferKeyCache into the SDK. It resolves an exchange DOMAIN
|
|
4
|
+
// (Offer.exchange) to that exchange's active Ed25519 offer-signing key, caching
|
|
5
|
+
// per domain with a TTL whose expiry is CLAMPED to the key's not_after, folding
|
|
6
|
+
// revocation screening into selection, and coalescing concurrent resolves for a
|
|
7
|
+
// domain into a single directory fetch. It IMPLEMENTS core/verifier.ts
|
|
8
|
+
// OfferKeyResolver (resolve(exchange) → Promise<key | undefined>), so
|
|
9
|
+
// createVerifier({ resolve }) injects it directly.
|
|
10
|
+
//
|
|
11
|
+
// It ASSEMBLES existing SDK primitives rather than reinventing them:
|
|
12
|
+
// - activeEd25519KeyWithExpiryScreened (wba.ts) — the revocation-aware
|
|
13
|
+
// document-order active-key selector with the not_after to clamp against.
|
|
14
|
+
// - the host-keyed TTL-cache + per-host single-flight shape mirrored from
|
|
15
|
+
// EndpointResolverImpl (wellknown.ts).
|
|
16
|
+
// - the SSRF-guarded fetch seam (http.ts) for the default directory fetcher.
|
|
17
|
+
//
|
|
18
|
+
// CORE INVARIANT (fail-closed): core.Verifier awaits resolve() with NO try/catch
|
|
19
|
+
// (verifier.ts check()), so a throw would fail-CRASH the whole verification batch
|
|
20
|
+
// instead of fail-closed-REJECTING the one offer. resolve() therefore NEVER
|
|
21
|
+
// throws: every fallible step — the injected fetch, the async selector (its
|
|
22
|
+
// thumbprint screening), and the caller-supplied revoked() callback — is wrapped
|
|
23
|
+
// so any throw or absence becomes `undefined`. Only a successful selection caches
|
|
24
|
+
// and returns a key. TS is fail-closed by ABSENCE (undefined), matching Python's
|
|
25
|
+
// absence-from-map — NOT Go's error return. The one exception is a MISSING fetch
|
|
26
|
+
// seam, which is a programmer error surfaced at CONSTRUCTION (TypeError), never a
|
|
27
|
+
// per-resolve absence — mirroring Go's PanicsWithoutFetch.
|
|
28
|
+
import { WBAFileSchema } from "../../../gen/ts/wire/schemas.js";
|
|
29
|
+
import { fetchStrict, guardedFetchFromEnv } from "./http.js";
|
|
30
|
+
import { activeEd25519KeyWithExpiryScreened, wbaDirectoryURL } from "./wba.js";
|
|
31
|
+
/** Default per-domain cache TTL (5 min), matching the Go/Python resolver. */
|
|
32
|
+
const DEFAULT_TTL_MS = 300_000;
|
|
33
|
+
/**
|
|
34
|
+
* clampOfferKeyExpiry bounds a cache entry's expiry to the key's validity window:
|
|
35
|
+
* the entry expires at min(nowMs+ttlMs, notAfterMs), so a key is never served past
|
|
36
|
+
* its not_after even within the TTL. A pure helper (all epoch-ms) so the
|
|
37
|
+
* tri-language clamp corpus replays the SAME arithmetic the resolver runs, not a
|
|
38
|
+
* re-inlined copy that could silently drift.
|
|
39
|
+
*/
|
|
40
|
+
export function clampOfferKeyExpiry(nowMs, ttlMs, notAfterMs) {
|
|
41
|
+
return Math.min(nowMs + ttlMs, notAfterMs);
|
|
42
|
+
}
|
|
43
|
+
class CachedOfferKeyResolverImpl {
|
|
44
|
+
fetchFn;
|
|
45
|
+
ttlMs;
|
|
46
|
+
now;
|
|
47
|
+
revoked;
|
|
48
|
+
cache = new Map();
|
|
49
|
+
flight = new Map();
|
|
50
|
+
constructor(opts) {
|
|
51
|
+
// A missing fetch seam is a programmer error, surfaced at CONSTRUCTION — never
|
|
52
|
+
// a per-resolve fail-closed absence (mirrors Go PanicsWithoutFetch).
|
|
53
|
+
if (typeof opts.fetch !== "function") {
|
|
54
|
+
throw new TypeError("CachedOfferKeyResolver requires a fetch directory seam");
|
|
55
|
+
}
|
|
56
|
+
this.fetchFn = opts.fetch;
|
|
57
|
+
this.ttlMs = opts.ttlMs && opts.ttlMs > 0 ? opts.ttlMs : DEFAULT_TTL_MS;
|
|
58
|
+
this.now = opts.now ?? Date.now;
|
|
59
|
+
this.revoked = opts.revoked ?? (() => false);
|
|
60
|
+
}
|
|
61
|
+
async resolve(exchange) {
|
|
62
|
+
const hit = this.cached(exchange);
|
|
63
|
+
if (hit !== undefined)
|
|
64
|
+
return hit;
|
|
65
|
+
let flight = this.flight.get(exchange);
|
|
66
|
+
if (!flight) {
|
|
67
|
+
flight = this.fetchAndSelect(exchange);
|
|
68
|
+
this.flight.set(exchange, flight);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
// fetchAndSelect is total (all failures → undefined), so awaiting the flight
|
|
72
|
+
// cannot throw — resolve() upholds the never-throw Core Invariant.
|
|
73
|
+
return await flight;
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
this.flight.delete(exchange);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
cached(exchange) {
|
|
80
|
+
const entry = this.cache.get(exchange);
|
|
81
|
+
// Miss at or past the (clamped) expiry — hit strictly before it, so a key whose
|
|
82
|
+
// clamped entry expires exactly at not_after is refetched at not_after (and then
|
|
83
|
+
// fails closed, since it is no longer window-active). Matches the Go read check.
|
|
84
|
+
if (!entry || this.now() >= entry.exp)
|
|
85
|
+
return undefined;
|
|
86
|
+
return entry.key;
|
|
87
|
+
}
|
|
88
|
+
async fetchAndSelect(exchange) {
|
|
89
|
+
try {
|
|
90
|
+
const hit = this.cached(exchange);
|
|
91
|
+
if (hit !== undefined)
|
|
92
|
+
return hit; // filled while we queued behind the flight
|
|
93
|
+
const dir = await this.fetchFn(exchange);
|
|
94
|
+
if (dir === undefined)
|
|
95
|
+
return undefined; // unresolvable directory → fail closed
|
|
96
|
+
const nowMs = this.now();
|
|
97
|
+
const selected = await activeEd25519KeyWithExpiryScreened(dir, nowMs, this.revoked);
|
|
98
|
+
if (selected === null)
|
|
99
|
+
return undefined; // no active, non-revoked key
|
|
100
|
+
// The decoded key bytes are ArrayBuffer-backed at runtime; the selector widens
|
|
101
|
+
// them to ArrayBufferLike, so pin the generic (base64url.ts convention) to
|
|
102
|
+
// satisfy OfferKeyResolver's Uint8Array<ArrayBuffer> contract under tsc --strict.
|
|
103
|
+
const key = selected.key;
|
|
104
|
+
const exp = clampOfferKeyExpiry(nowMs, this.ttlMs, selected.notAfter);
|
|
105
|
+
this.cache.set(exchange, { key, exp });
|
|
106
|
+
return key;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Core Invariant: ANY throw (fetch seam, async thumbprint screening, or the
|
|
110
|
+
// caller-supplied revoked() callback) fails closed to undefined — never
|
|
111
|
+
// propagates, since core.Verifier awaits resolve() with no try/catch.
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* createCachedOfferKeyResolver builds a domain-keyed offer-key cache implementing
|
|
118
|
+
* OfferKeyResolver. `newX` for family consistency with the sibling resolver
|
|
119
|
+
* factories (createWBAKeyResolver, createWellKnownKeyResolver, …). Throws TypeError when
|
|
120
|
+
* opts.fetch is missing; every other failure fails closed at resolve() time.
|
|
121
|
+
*/
|
|
122
|
+
export function createCachedOfferKeyResolver(opts) {
|
|
123
|
+
return new CachedOfferKeyResolverImpl(opts);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* createWBAOfferDirectoryFetch returns the default {@link OfferDirectoryFetch}: it
|
|
127
|
+
* GETs {scheme}://{domain}[:{port}]{WBA_DIRECTORY_PATH} (built by the shared
|
|
128
|
+
* wbaDirectoryURL) and parses the body as a WBAFile, returning `undefined` on ANY
|
|
129
|
+
* transport/status/decode failure so
|
|
130
|
+
* the default fetcher itself upholds the undefined-not-throw seam contract.
|
|
131
|
+
*
|
|
132
|
+
* The default transport is SSRF-guarded (guardedFetchFromEnv): the exchange domain
|
|
133
|
+
* is signature-covered but attacker-influenceable and the fetch runs before the
|
|
134
|
+
* offer signature is checked, so an unguarded default would be a pre-auth SSRF
|
|
135
|
+
* lever (mirrors Go NewWBADirectoryFetcher). Tests inject a loopback fetch; apps
|
|
136
|
+
* may inject a fetch wrapping their shared well-known client.
|
|
137
|
+
*/
|
|
138
|
+
export function createWBAOfferDirectoryFetch(opts = {}) {
|
|
139
|
+
const fetchFn = opts.fetch ?? guardedFetchFromEnv();
|
|
140
|
+
const scheme = opts.scheme && opts.scheme !== "" ? opts.scheme : "https";
|
|
141
|
+
const port = opts.port ?? "";
|
|
142
|
+
return async (domain) => {
|
|
143
|
+
try {
|
|
144
|
+
const host = port !== "" ? `${domain}:${port}` : domain;
|
|
145
|
+
const body = await fetchStrict(fetchFn, wbaDirectoryURL(scheme, host));
|
|
146
|
+
return WBAFileSchema.parse(JSON.parse(body));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type RegistrationSchema, type SchemaVerdict } from "../src/regschema.ts";
|
|
2
|
+
import { type FetchLike } from "./http.ts";
|
|
3
|
+
/** What one Exchange asks of a registration. Both members are optional in the
|
|
4
|
+
* contract, and their absence is a normal answer rather than a failure. */
|
|
5
|
+
export interface RegistrationRequirements {
|
|
6
|
+
/** The manifest's `terms_digest`, `undefined` when the Exchange publishes none.
|
|
7
|
+
* Copy it onto `RegisterRequest.terms_digest` unchanged: the request signature
|
|
8
|
+
* covers the echo, and that echo is the durable record of which terms revision
|
|
9
|
+
* the operator accepted. */
|
|
10
|
+
termsDigest: string | undefined;
|
|
11
|
+
/** Validates `registration_data` before anything is signed. `null` in TWO cases
|
|
12
|
+
* — the Exchange publishes none, and it publishes one this SDK refuses — and
|
|
13
|
+
* `verdict` is what tells them apart. Both are deliberately the same VALUE,
|
|
14
|
+
* because the contract requires a client that cannot check locally to send
|
|
15
|
+
* anyway: a local check that cannot run must not become a local veto. */
|
|
16
|
+
schema: RegistrationSchema | null;
|
|
17
|
+
/** The SDK's answer for the published schema. `not_published` is the ordinary
|
|
18
|
+
* absent case; `accepted` means `schema` is usable; anything else names why a
|
|
19
|
+
* published schema was refused, which is worth logging and is never worth
|
|
20
|
+
* refusing the registration over. */
|
|
21
|
+
verdict: SchemaVerdict;
|
|
22
|
+
}
|
|
23
|
+
/** The well-known registration-requirements face, named for the concrete reader
|
|
24
|
+
* the oracle exposes (`resolvers.WellKnownRequirementsReader`). */
|
|
25
|
+
export interface WellKnownRequirementsReader {
|
|
26
|
+
resolveRegistrationRequirements(exchange: string): Promise<RegistrationRequirements>;
|
|
27
|
+
}
|
|
28
|
+
/** Options for the reader. `ttlMs` and `now` are deliberately absent: it caches
|
|
29
|
+
* nothing, so it has no freshness to compute. */
|
|
30
|
+
export interface WellKnownRequirementsOptions {
|
|
31
|
+
/** The transport. Omitted, it is the SSRF-GUARDED one: the domain is
|
|
32
|
+
* caller-named — an agent registers at whichever Exchange it means to transact
|
|
33
|
+
* with, and that domain routinely arrives at runtime rather than from
|
|
34
|
+
* configuration — so this is request-derived provenance, which takes the guarded
|
|
35
|
+
* default. A deployment that must reach a private or loopback Exchange injects
|
|
36
|
+
* its own transport here, or opts out through the SKIP_SSRF / ALLOW_INSECURE
|
|
37
|
+
* environment flags. */
|
|
38
|
+
fetch?: FetchLike;
|
|
39
|
+
/** Trust allowlist consulted BEFORE the fetch. A domain it rejects never
|
|
40
|
+
* reaches the network. */
|
|
41
|
+
allow?: (domain: string) => boolean;
|
|
42
|
+
/** The URL scheme used to build `{scheme}://{domain}/.well-known/fora.json`
|
|
43
|
+
* (tests inject "http"). */
|
|
44
|
+
scheme?: string;
|
|
45
|
+
}
|
|
46
|
+
/** Read registration requirements from an Exchange's own well-known manifest. */
|
|
47
|
+
export declare function createWellKnownRequirementsReader(opts?: WellKnownRequirementsOptions): WellKnownRequirementsReader;
|