@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,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,2 @@
1
+ /** Extract the Ed25519 public keys (keyed by kid) from a parsed JWKS doc. */
2
+ export declare function ed25519KeysFromJwks(doc: unknown): Map<string, Uint8Array>;
@@ -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;