@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,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EndpointResolver turns a signed exchange domain into the origin that Exchange
|
|
3
|
+
* advertises for itself. It is an interface so a test can drive reporting without
|
|
4
|
+
* standing up a manifest server — and, more to the point, so this module has no way to
|
|
5
|
+
* accept a report endpoint from configuration.
|
|
6
|
+
*
|
|
7
|
+
* An implementation's FAILURE decides how a caller is told to react, so it is part of the
|
|
8
|
+
* contract rather than an implementation detail. A failure that is a VERDICT — the host
|
|
9
|
+
* is unusable, the host is not allowed, the manifest carries a version this reader does
|
|
10
|
+
* not accept, advertises no endpoint, or advertises one that must not be used — MUST
|
|
11
|
+
* throw the resolver tier's ManifestVersionRefused, NoEndpoint or EndpointRefused, or
|
|
12
|
+
* the invalid-host error isBareHost raises; those surface as `not_sent`, which tells
|
|
13
|
+
* the caller not to retry. Anything else is read as a transport
|
|
14
|
+
* failure and reported as `unreachable`, i.e. worth retrying. An implementation that
|
|
15
|
+
* throws a bare error for a refusal therefore has its final answer retried indefinitely.
|
|
16
|
+
*/
|
|
17
|
+
export interface EndpointResolver {
|
|
18
|
+
resolveEndpoint(host: string): Promise<string>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* vetExchangeEndpoint resolves an exchange domain to an origin a signed call may be sent
|
|
22
|
+
* to, or refuses — naming the check that declined, and classifying it by CAUSE. "The
|
|
23
|
+
* Exchange said no", "we could not reach it" and "we refused to dial it" are three
|
|
24
|
+
* different outcomes calling for three different responses, and only the class tells them
|
|
25
|
+
* apart: a verdict is final, a transport failure is worth retrying.
|
|
26
|
+
*
|
|
27
|
+
* It is one function so the vetting reads in one place: the checks are the part that
|
|
28
|
+
* grows, and seeing them together is what makes it evident that no branch falls through
|
|
29
|
+
* to the send.
|
|
30
|
+
*/
|
|
31
|
+
export declare function vetExchangeEndpoint(resolver: EndpointResolver | undefined, exchangeDomain: string, op: string): Promise<string>;
|
|
32
|
+
/** Whether a cause is the invalid-host refusal.
|
|
33
|
+
*
|
|
34
|
+
* Matched on its wording rather than its type. The host helpers are L1 and deliberately
|
|
35
|
+
* raise a bare error for it — a typed InvalidHost would cross the helpers-versus-resolvers
|
|
36
|
+
* sentinel split the SDK documents — so the shared prefix is what there is to match.
|
|
37
|
+
*
|
|
38
|
+
* Exported because TWO injectable readers can raise it, the endpoint resolver and the
|
|
39
|
+
* registration-requirements reader, and both have to call it final for the same reason. A
|
|
40
|
+
* second copy of the prefix is exactly how the two would come to disagree. */
|
|
41
|
+
export declare function isInvalidHostRefusal(cause: unknown): boolean;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Routing a call to the Exchange that ISSUED an offer. TS port of sdk/go/connect/route.go.
|
|
2
|
+
//
|
|
3
|
+
// There are three kinds of destination, and the difference decides how much checking a
|
|
4
|
+
// call needs. The Broker and the home Exchange come from the client's own configuration
|
|
5
|
+
// and are trusted as far as that configuration is. The Exchange a usage report goes to is
|
|
6
|
+
// named inside an OFFER, which arrived over the network — so an actor who can influence
|
|
7
|
+
// an offer can influence that address.
|
|
8
|
+
//
|
|
9
|
+
// A signature covers the DOMAIN; it says nothing about where that domain's endpoint
|
|
10
|
+
// lives, or where its DNS points. That is why the address is resolved from the Exchange's
|
|
11
|
+
// own manifest and then checked, rather than taken on trust or, worse, read from
|
|
12
|
+
// configuration.
|
|
13
|
+
import { EndpointRefused, ManifestVersionRefused, NoEndpoint } from "./../resolvers/errors.js";
|
|
14
|
+
import { endpointRefusal } from "../src/endpoint-rule.js";
|
|
15
|
+
import { isBareHost } from "../src/hosts.js";
|
|
16
|
+
import { redactUserinfo } from "../src/host-ref.js";
|
|
17
|
+
import { ForaCallError, notSent } from "./errors.js";
|
|
18
|
+
/**
|
|
19
|
+
* vetExchangeEndpoint resolves an exchange domain to an origin a signed call may be sent
|
|
20
|
+
* to, or refuses — naming the check that declined, and classifying it by CAUSE. "The
|
|
21
|
+
* Exchange said no", "we could not reach it" and "we refused to dial it" are three
|
|
22
|
+
* different outcomes calling for three different responses, and only the class tells them
|
|
23
|
+
* apart: a verdict is final, a transport failure is worth retrying.
|
|
24
|
+
*
|
|
25
|
+
* It is one function so the vetting reads in one place: the checks are the part that
|
|
26
|
+
* grows, and seeing them together is what makes it evident that no branch falls through
|
|
27
|
+
* to the send.
|
|
28
|
+
*/
|
|
29
|
+
export async function vetExchangeEndpoint(resolver, exchangeDomain, op) {
|
|
30
|
+
if (resolver === undefined) {
|
|
31
|
+
throw notSent(op, new Error("no endpoint resolver configured"));
|
|
32
|
+
}
|
|
33
|
+
if (exchangeDomain === "") {
|
|
34
|
+
throw notSent(op, new Error("no exchange domain to route to; it comes from the signed offer"));
|
|
35
|
+
}
|
|
36
|
+
// A plain hostname, checked here even if the caller checked it. The resolver builds
|
|
37
|
+
// its URL by concatenating this value, so a path or query smuggled through would
|
|
38
|
+
// choose what gets fetched; this module owns the call and cannot rely on every present
|
|
39
|
+
// and future caller having vetted it.
|
|
40
|
+
let bare;
|
|
41
|
+
try {
|
|
42
|
+
bare = isBareHost(exchangeDomain);
|
|
43
|
+
}
|
|
44
|
+
catch (cause) {
|
|
45
|
+
throw notSent(op, new Error(`exchange ${redactUserinfo(exchangeDomain)} is not a usable domain: ${cause instanceof Error ? cause.message : String(cause)}`));
|
|
46
|
+
}
|
|
47
|
+
if (!bare) {
|
|
48
|
+
throw notSent(op, new Error(`exchange ${redactUserinfo(exchangeDomain)} is not a bare domain, refusing to resolve it`));
|
|
49
|
+
}
|
|
50
|
+
let endpoint;
|
|
51
|
+
try {
|
|
52
|
+
endpoint = await resolver.resolveEndpoint(exchangeDomain);
|
|
53
|
+
}
|
|
54
|
+
catch (cause) {
|
|
55
|
+
// Classified by CAUSE, not by position. Reaching the manifest is a network
|
|
56
|
+
// operation, and a DNS blip or a 500 from an otherwise healthy Exchange is
|
|
57
|
+
// TRANSIENT — reporting it as a refusal would tell a caller "we declined to send
|
|
58
|
+
// this, do not retry" and permanently drop a usage report over a momentary outage.
|
|
59
|
+
// Only a verdict is a refusal: the value was not a usable host, the host was not
|
|
60
|
+
// allowed, the manifest was read and advertises no endpoint at all, or it
|
|
61
|
+
// advertises one the resolver will not hand back.
|
|
62
|
+
throw new ForaCallError({
|
|
63
|
+
kind: isVerdict(cause) ? "not_sent" : "unreachable",
|
|
64
|
+
op,
|
|
65
|
+
cause: new Error(`resolve exchange ${exchangeDomain}: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// Re-checked here even though the SDK's own resolver already refuses such an endpoint.
|
|
69
|
+
// The resolver is an injectable seam — its own docs offer it so a caller can drive
|
|
70
|
+
// reporting without a manifest server — and this module cannot make a SIGNED call
|
|
71
|
+
// conditional on a stranger's implementation having remembered the rule. The cost is
|
|
72
|
+
// string work on a path that just did a network fetch.
|
|
73
|
+
//
|
|
74
|
+
// The SAME predicate both times, deliberately. Stated twice it drifts, and a
|
|
75
|
+
// half-mirrored version of this rule is how a signed call ends up carrying credentials
|
|
76
|
+
// the SDK never chose.
|
|
77
|
+
const refusal = endpointRefusal(exchangeDomain, endpoint);
|
|
78
|
+
if (refusal !== undefined) {
|
|
79
|
+
throw notSent(op, new Error(`refusing to send a signed call to the endpoint exchange ${redactUserinfo(exchangeDomain)} advertises: ${refusal}`));
|
|
80
|
+
}
|
|
81
|
+
return endpoint;
|
|
82
|
+
}
|
|
83
|
+
/** Whether a cause is the invalid-host refusal.
|
|
84
|
+
*
|
|
85
|
+
* Matched on its wording rather than its type. The host helpers are L1 and deliberately
|
|
86
|
+
* raise a bare error for it — a typed InvalidHost would cross the helpers-versus-resolvers
|
|
87
|
+
* sentinel split the SDK documents — so the shared prefix is what there is to match.
|
|
88
|
+
*
|
|
89
|
+
* Exported because TWO injectable readers can raise it, the endpoint resolver and the
|
|
90
|
+
* registration-requirements reader, and both have to call it final for the same reason. A
|
|
91
|
+
* second copy of the prefix is exactly how the two would come to disagree. */
|
|
92
|
+
export function isInvalidHostRefusal(cause) {
|
|
93
|
+
return (cause instanceof Error &&
|
|
94
|
+
cause.message.startsWith("hosts: reference is not a usable host"));
|
|
95
|
+
}
|
|
96
|
+
// isVerdict tells the resolver's final answers from its transient ones. The invalid-host
|
|
97
|
+
// error is in the set because the resolver checks the host itself too, and a value that
|
|
98
|
+
// is not a host will not become one on a later attempt. This module checks it before
|
|
99
|
+
// resolving, so the SDK's own resolver never reaches here that way — an injected one can.
|
|
100
|
+
function isVerdict(cause) {
|
|
101
|
+
if (cause instanceof ManifestVersionRefused ||
|
|
102
|
+
cause instanceof NoEndpoint ||
|
|
103
|
+
cause instanceof EndpointRefused) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return isInvalidHostRefusal(cause);
|
|
107
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type UnarySend } from "./transport.ts";
|
|
2
|
+
/** How the default send decides whether to dial through the SSRF guard. */
|
|
3
|
+
export interface SendOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Whether the dial-time address guard is applied. It exists for the legs whose host
|
|
6
|
+
* is chosen by a party on the network — the offer-derived Exchange and the delivery
|
|
7
|
+
* edge. The home Exchange and the Broker are operator-configured, so their address is
|
|
8
|
+
* trusted as far as that configuration is.
|
|
9
|
+
*/
|
|
10
|
+
guarded: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* createUnarySend returns the SDK's own dialing seam.
|
|
14
|
+
*
|
|
15
|
+
* One dispatcher per send, holding the guard connector and no redirect interceptor, so a
|
|
16
|
+
* proxied CONNECT cannot tunnel a private target past the dial guard and a 3xx arrives as
|
|
17
|
+
* an ordinary response this tier can classify.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createUnarySend(opts: SendOptions): UnarySend;
|
|
20
|
+
/**
|
|
21
|
+
* readBounded consumes a body under the caller's cap.
|
|
22
|
+
*
|
|
23
|
+
* It reads one byte past the cap so an oversized body is DETECTED rather than silently
|
|
24
|
+
* truncated. Truncated content that looks whole is worse than a refusal: a caller has no
|
|
25
|
+
* way to tell it is incomplete, and on the delivery leg it has already paid for it.
|
|
26
|
+
*/
|
|
27
|
+
export declare function readBounded(body: AsyncIterable<Uint8Array>, maxBytes: number, op?: string): Promise<string>;
|
|
28
|
+
/**
|
|
29
|
+
* reclaim releases the socket behind a response body that was not read to the end.
|
|
30
|
+
*
|
|
31
|
+
* Every refusal above a read is one of these: the check that rejects a coding, the class
|
|
32
|
+
* that rejects a redirect. Each throws while the body is still mid-stream, and undici
|
|
33
|
+
* cannot return a connection whose response nobody consumed — so the socket stays open
|
|
34
|
+
* until the PEER hangs up, and a peer that never does is exactly the party these guards
|
|
35
|
+
* exist to contain. Measured before this: six refusals left six live sockets, still six
|
|
36
|
+
* after fifteen seconds idle, on a dispatcher the delivery leg shares process-wide. With a
|
|
37
|
+
* bounded pool one refusal wedged it outright and the client's own deadline did not break
|
|
38
|
+
* it.
|
|
39
|
+
*
|
|
40
|
+
* Both siblings get this from an idiom TypeScript has no equivalent of — Go from
|
|
41
|
+
* `defer resp.Body.Close()`, Python from `with … stream()` — so here it is spelled out, in
|
|
42
|
+
* a `finally` rather than at each throw. That placement is the point: the last regression
|
|
43
|
+
* added a throw ABOVE the branch that happened to be doing the reclaiming, and a fix
|
|
44
|
+
* attached to the throws we can currently name would go the same way.
|
|
45
|
+
*
|
|
46
|
+
* DESTROY rather than drain. Reading the body to free the socket would be an unbounded read
|
|
47
|
+
* from a peer whose answer is already refused, and draining under a cap is what undici's
|
|
48
|
+
* `dump` does — but `dump` settles only when the stream closes, so awaiting it hands a peer
|
|
49
|
+
* that trickles control of how long the refusal takes. Nothing is owed to a body that is
|
|
50
|
+
* being discarded.
|
|
51
|
+
*
|
|
52
|
+
* Idempotent, so the paths that already consumed can run through it unharmed: a read that
|
|
53
|
+
* finished has ended, and one that threw mid-iteration was destroyed by the loop's own
|
|
54
|
+
* exit. `destroy` alone would be safe on an ended stream — undici raises the abort only
|
|
55
|
+
* while `endEmitted` is false — but the guard says so where a reader will see it.
|
|
56
|
+
*/
|
|
57
|
+
export declare function reclaim(body: {
|
|
58
|
+
readonly destroyed: boolean;
|
|
59
|
+
readonly readableEnded: boolean;
|
|
60
|
+
on: (event: "error", listener: () => void) => unknown;
|
|
61
|
+
destroy: () => void;
|
|
62
|
+
}): void;
|
|
63
|
+
/** concat joins the read chunks into one buffer. */
|
|
64
|
+
export declare function concat(chunks: Uint8Array[], total: number): Uint8Array;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The default dialing seam for the client tier.
|
|
2
|
+
//
|
|
3
|
+
// It runs on undici, like the resolver tier's transport, with one deliberate difference:
|
|
4
|
+
// REDIRECTS ARE REFUSED rather than followed under a cap. The resolvers fetch public
|
|
5
|
+
// well-known documents, where following a hop is harmless; every leg here carries a
|
|
6
|
+
// credential — an RFC 9421 signature, or a proof of possession bound to one URL — and
|
|
7
|
+
// following a 3xx would either replay that credential at a host the peer chose or mint a
|
|
8
|
+
// fresh one for it. A 3xx is an answer to report, never a hop to take.
|
|
9
|
+
//
|
|
10
|
+
// The address pin is composed here rather than accepted already built: this factory takes
|
|
11
|
+
// what sits UNDER it, never what replaces it. It matters most on the offer-derived leg,
|
|
12
|
+
// where the caller names a domain, the manifest it serves names an endpoint, and a signed
|
|
13
|
+
// call then goes there — without the pin one hop down, that is a signed request aimed at
|
|
14
|
+
// an arbitrary internal address.
|
|
15
|
+
//
|
|
16
|
+
// A caller CAN replace this whole factory, by passing their own UnarySend to the client.
|
|
17
|
+
// That takes the address pin with it, which is the same latitude the Python client gives
|
|
18
|
+
// an injected httpx client and more than Go gives at all. What it does NOT take is the
|
|
19
|
+
// scheme gate: that lives in unaryCall, above the send, so no injected dial reaches a
|
|
20
|
+
// plaintext endpoint carrying a signature.
|
|
21
|
+
//
|
|
22
|
+
// An edge runtime with no undici injects its own send. That is the same escape hatch the
|
|
23
|
+
// resolver tier offers, and the same obligations come with it: no redirects, no reading
|
|
24
|
+
// past the caller's byte bound, and the lifetime of a body it does NOT read — a refusal
|
|
25
|
+
// that walks away from an unread stream holds the socket open until the peer hangs up,
|
|
26
|
+
// and a peer that never does is the case the guard exists for. See reclaim.
|
|
27
|
+
import { Agent, request as undiciRequest } from "undici";
|
|
28
|
+
import { skipSSRF, ssrfGuard } from "../resolvers/http.js";
|
|
29
|
+
import { ForaCallError } from "./errors.js";
|
|
30
|
+
import { refuseUnrequestedEncoding, } from "./transport.js";
|
|
31
|
+
/**
|
|
32
|
+
* createUnarySend returns the SDK's own dialing seam.
|
|
33
|
+
*
|
|
34
|
+
* One dispatcher per send, holding the guard connector and no redirect interceptor, so a
|
|
35
|
+
* proxied CONNECT cannot tunnel a private target past the dial guard and a 3xx arrives as
|
|
36
|
+
* an ordinary response this tier can classify.
|
|
37
|
+
*/
|
|
38
|
+
export function createUnarySend(opts) {
|
|
39
|
+
// The address guard, unless the deployment turned it off — the same SKIP_SSRF switch
|
|
40
|
+
// Go reads in NewGuardedTransport and Python in guarded_client. The scheme gate below
|
|
41
|
+
// is separate and is NOT covered by that flag; ALLOW_INSECURE is its own decision.
|
|
42
|
+
const dispatcher = opts.guarded && !skipSSRF() ? new Agent({ connect: ssrfGuard() }) : new Agent();
|
|
43
|
+
return async (req) => {
|
|
44
|
+
const response = await undiciRequest(req.url, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: req.headers,
|
|
47
|
+
body: req.body,
|
|
48
|
+
signal: req.signal,
|
|
49
|
+
dispatcher,
|
|
50
|
+
// undici follows nothing unless the redirect interceptor is composed in, so
|
|
51
|
+
// the refusal is the absence of that interceptor rather than a setting.
|
|
52
|
+
maxRedirections: 0,
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
// Before a byte is read: a coding the client did not negotiate cannot be
|
|
56
|
+
// bounded, because a decoder expands a whole raw read at once. The headers are
|
|
57
|
+
// only visible here, which is why the check lives in the send rather than
|
|
58
|
+
// beside the decode.
|
|
59
|
+
refuseUnrequestedEncoding(req.op, response.statusCode, response.headers);
|
|
60
|
+
return {
|
|
61
|
+
status: response.statusCode,
|
|
62
|
+
body: await readBounded(response.body, req.maxBytes, req.op),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
reclaim(response.body);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* readBounded consumes a body under the caller's cap.
|
|
72
|
+
*
|
|
73
|
+
* It reads one byte past the cap so an oversized body is DETECTED rather than silently
|
|
74
|
+
* truncated. Truncated content that looks whole is worse than a refusal: a caller has no
|
|
75
|
+
* way to tell it is incomplete, and on the delivery leg it has already paid for it.
|
|
76
|
+
*/
|
|
77
|
+
export async function readBounded(body, maxBytes, op = "read response") {
|
|
78
|
+
const chunks = [];
|
|
79
|
+
let total = 0;
|
|
80
|
+
for await (const chunk of body) {
|
|
81
|
+
total += chunk.length;
|
|
82
|
+
if (total > maxBytes) {
|
|
83
|
+
throw new ForaCallError({
|
|
84
|
+
kind: "too_large",
|
|
85
|
+
op,
|
|
86
|
+
cause: new Error(`body exceeds the ${maxBytes} byte cap`),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
chunks.push(chunk);
|
|
90
|
+
}
|
|
91
|
+
return new TextDecoder().decode(concat(chunks, total));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* reclaim releases the socket behind a response body that was not read to the end.
|
|
95
|
+
*
|
|
96
|
+
* Every refusal above a read is one of these: the check that rejects a coding, the class
|
|
97
|
+
* that rejects a redirect. Each throws while the body is still mid-stream, and undici
|
|
98
|
+
* cannot return a connection whose response nobody consumed — so the socket stays open
|
|
99
|
+
* until the PEER hangs up, and a peer that never does is exactly the party these guards
|
|
100
|
+
* exist to contain. Measured before this: six refusals left six live sockets, still six
|
|
101
|
+
* after fifteen seconds idle, on a dispatcher the delivery leg shares process-wide. With a
|
|
102
|
+
* bounded pool one refusal wedged it outright and the client's own deadline did not break
|
|
103
|
+
* it.
|
|
104
|
+
*
|
|
105
|
+
* Both siblings get this from an idiom TypeScript has no equivalent of — Go from
|
|
106
|
+
* `defer resp.Body.Close()`, Python from `with … stream()` — so here it is spelled out, in
|
|
107
|
+
* a `finally` rather than at each throw. That placement is the point: the last regression
|
|
108
|
+
* added a throw ABOVE the branch that happened to be doing the reclaiming, and a fix
|
|
109
|
+
* attached to the throws we can currently name would go the same way.
|
|
110
|
+
*
|
|
111
|
+
* DESTROY rather than drain. Reading the body to free the socket would be an unbounded read
|
|
112
|
+
* from a peer whose answer is already refused, and draining under a cap is what undici's
|
|
113
|
+
* `dump` does — but `dump` settles only when the stream closes, so awaiting it hands a peer
|
|
114
|
+
* that trickles control of how long the refusal takes. Nothing is owed to a body that is
|
|
115
|
+
* being discarded.
|
|
116
|
+
*
|
|
117
|
+
* Idempotent, so the paths that already consumed can run through it unharmed: a read that
|
|
118
|
+
* finished has ended, and one that threw mid-iteration was destroyed by the loop's own
|
|
119
|
+
* exit. `destroy` alone would be safe on an ended stream — undici raises the abort only
|
|
120
|
+
* while `endEmitted` is false — but the guard says so where a reader will see it.
|
|
121
|
+
*/
|
|
122
|
+
export function reclaim(body) {
|
|
123
|
+
if (body.destroyed || body.readableEnded)
|
|
124
|
+
return;
|
|
125
|
+
// Tearing down a stream that has not ended raises an abort ON that stream, and an
|
|
126
|
+
// "error" nothing is listening for is an uncaught exception in Node — so releasing the
|
|
127
|
+
// socket would take the caller's process down on every refusal, which is worse than the
|
|
128
|
+
// leak. The listener is what makes the teardown survivable, and undici's own discard
|
|
129
|
+
// helper carries the same one for the same reason.
|
|
130
|
+
body.on("error", () => { });
|
|
131
|
+
body.destroy();
|
|
132
|
+
}
|
|
133
|
+
/** concat joins the read chunks into one buffer. */
|
|
134
|
+
export function concat(chunks, total) {
|
|
135
|
+
const out = new Uint8Array(total);
|
|
136
|
+
let at = 0;
|
|
137
|
+
for (const chunk of chunks) {
|
|
138
|
+
out.set(chunk, at);
|
|
139
|
+
at += chunk.length;
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { Window } from "../core/window.ts";
|
|
2
|
+
/**
|
|
3
|
+
* DEFAULT_MAX_RPC_READ_BYTES caps the response body a single FORA call will read.
|
|
4
|
+
*
|
|
5
|
+
* A FORA response for a realistic batch is small; the bound is what stops a peer —
|
|
6
|
+
* including one an offer named — spending the caller's memory on its behalf. Mirrors the
|
|
7
|
+
* Go client's DefaultMaxRPCReadBytes.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_MAX_RPC_READ_BYTES: number;
|
|
10
|
+
/**
|
|
11
|
+
* DEFAULT_CALL_TIMEOUT_MS bounds one call. A FORA RPC is interactive — something is
|
|
12
|
+
* waiting on the other end — so a request that has not answered by now is more useful as
|
|
13
|
+
* an error than as a hang. Mirrors the Go client's DefaultCallTimeout.
|
|
14
|
+
*/
|
|
15
|
+
export declare const DEFAULT_CALL_TIMEOUT_MS = 30000;
|
|
16
|
+
/** One outbound unary request, after the SDK has assembled and signed it. */
|
|
17
|
+
export interface UnaryRequest {
|
|
18
|
+
url: string;
|
|
19
|
+
headers: Record<string, string>;
|
|
20
|
+
body: Uint8Array<ArrayBuffer>;
|
|
21
|
+
/** Aborts the send when the call's deadline elapses. */
|
|
22
|
+
signal: AbortSignal;
|
|
23
|
+
/** The body bound the send must not read past. */
|
|
24
|
+
maxBytes: number;
|
|
25
|
+
/** The verb, so a failure raised inside the send names the call rather than the
|
|
26
|
+
* mechanism — Go and Python both report it. */
|
|
27
|
+
op: string;
|
|
28
|
+
}
|
|
29
|
+
/** What a send returns. The body is already read, bounded, as text. */
|
|
30
|
+
export interface UnaryResponse {
|
|
31
|
+
status: number;
|
|
32
|
+
body: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The seam the transport dials through. Injected, so an edge runtime supplies its own and
|
|
36
|
+
* a test drives the whole client without a socket.
|
|
37
|
+
*
|
|
38
|
+
* An implementation MUST honour `signal`: the tier above sets the call deadline on it and
|
|
39
|
+
* has no other way to stop a send that never returns. The shipped send passes it to undici.
|
|
40
|
+
*
|
|
41
|
+
* What an implementation is NOT responsible for is the scheme: `unaryCall` refuses a URL it
|
|
42
|
+
* will not dial before this is ever called, so a send cannot admit one by omission. What it
|
|
43
|
+
* DOES take on by replacing the shipped send is the dial-time address pin — the check that
|
|
44
|
+
* a hostname resolves somewhere public — which the default composes and an injected send
|
|
45
|
+
* must compose for itself if it needs one.
|
|
46
|
+
*
|
|
47
|
+
* An implementation MUST NOT follow redirects. Following one would re-sign the caller's
|
|
48
|
+
* request for a target the peer chose, after the endpoint check had already passed — a
|
|
49
|
+
* 3xx is an answer to be reported, never a hop to take. It MUST also refuse to read past
|
|
50
|
+
* `maxBytes`, and signal that by throwing a ForaCallError of kind `too_large`.
|
|
51
|
+
*/
|
|
52
|
+
export type UnarySend = (req: UnaryRequest) => Promise<UnaryResponse>;
|
|
53
|
+
/** Where a call is addressed: the origin, the fully-qualified service, and the method. */
|
|
54
|
+
export interface UnaryTarget {
|
|
55
|
+
baseURL: string;
|
|
56
|
+
service: string;
|
|
57
|
+
method: string;
|
|
58
|
+
}
|
|
59
|
+
/** The identity a signed call carries. Absent means the call goes out unsigned. */
|
|
60
|
+
export interface CallSigner {
|
|
61
|
+
privKey: CryptoKey;
|
|
62
|
+
keyid: string;
|
|
63
|
+
/** The WBA directory origin this client signs as. Covered by the signature even when
|
|
64
|
+
* empty, so it is passed through verbatim rather than defaulted here. */
|
|
65
|
+
signatureAgent?: string;
|
|
66
|
+
/** The RFC 9421 freshness window. Defaults to the signing transport's own. */
|
|
67
|
+
window?: Window;
|
|
68
|
+
}
|
|
69
|
+
/** Everything one unary call needs beyond its message. */
|
|
70
|
+
export interface UnaryCallOptions {
|
|
71
|
+
target: UnaryTarget;
|
|
72
|
+
op: string;
|
|
73
|
+
/** The request message, already validated by its generated schema. */
|
|
74
|
+
message: unknown;
|
|
75
|
+
send: UnarySend;
|
|
76
|
+
/** Whether this leg dials a host another party named — an offer-derived Exchange. The
|
|
77
|
+
* scheme is gated here rather than inside the send, so replacing the send cannot
|
|
78
|
+
* remove it. */
|
|
79
|
+
guarded?: boolean;
|
|
80
|
+
signer?: CallSigner;
|
|
81
|
+
requestId?: () => string;
|
|
82
|
+
maxBytes?: number;
|
|
83
|
+
timeoutMs?: number;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* rpcURL joins an origin to the Connect unary path. The trailing slash is trimmed so a
|
|
87
|
+
* base URL written either way addresses the same method — a doubled slash is a different
|
|
88
|
+
* path to some servers and a 404 from them.
|
|
89
|
+
*/
|
|
90
|
+
export declare function rpcURL(target: UnaryTarget): string;
|
|
91
|
+
/**
|
|
92
|
+
* unaryCall sends one FORA RPC and returns the peer's answer as parsed JSON.
|
|
93
|
+
*
|
|
94
|
+
* The body is serialized ONCE and the same bytes are signed and sent: RFC 9530
|
|
95
|
+
* Content-Digest covers the exact octets, so re-serializing between signing and sending
|
|
96
|
+
* would produce a digest for a body the peer never received.
|
|
97
|
+
*/
|
|
98
|
+
export declare function unaryCall(opts: UnaryCallOptions): Promise<unknown>;
|
|
99
|
+
/**
|
|
100
|
+
* IDENTITY_ENCODING asks the peer for no content coding.
|
|
101
|
+
*
|
|
102
|
+
* Sending nothing is not the same thing: per RFC 9110 §12.5.3 an absent Accept-Encoding
|
|
103
|
+
* means ANY coding is acceptable, and undici does not decode one — so a gzipped answer
|
|
104
|
+
* would arrive as raw octets, fail to parse, and be reported as the peer's fault for
|
|
105
|
+
* something the peer was entitled to do.
|
|
106
|
+
*/
|
|
107
|
+
export declare const IDENTITY_ENCODING: {
|
|
108
|
+
readonly "accept-encoding": "identity";
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* refuseUnrequestedEncoding refuses a response carrying a content coding we did not ask for.
|
|
112
|
+
*
|
|
113
|
+
* Every leg negotiates identity, so a coding here is the peer answering that negotiation
|
|
114
|
+
* and then ignoring it. It is refused BEFORE the body is read, which is the only bound
|
|
115
|
+
* that holds at any chunk size — a decoder expands a whole raw read at once, so a running
|
|
116
|
+
* total over decoded chunks can be overshot by however much one chunk inflates to. The
|
|
117
|
+
* same check, for the same reason, is what bounds the Python client.
|
|
118
|
+
*
|
|
119
|
+
* `identity` itself is not a coding, and neither is an absent header.
|
|
120
|
+
*/
|
|
121
|
+
export declare function refuseUnrequestedEncoding(op: string, status: number, headers: Record<string, string | string[] | undefined>): void;
|
|
122
|
+
/**
|
|
123
|
+
* decodeResponse turns one answer into a parsed message, or throws the typed failure.
|
|
124
|
+
*
|
|
125
|
+
* A non-2xx is the Connect error envelope: `{code, message, details}`. The typed reason
|
|
126
|
+
* rides in `details`, which errorDetailFrom reads — including the lowerCamelCase `debug`
|
|
127
|
+
* projection connect-go emits there and no server codec replaces.
|
|
128
|
+
*/
|
|
129
|
+
export declare function decodeResponse(op: string, response: UnaryResponse): unknown;
|
|
130
|
+
/**
|
|
131
|
+
* NOT_CANONICAL_WIRE_NAMING is the reason a peer's answer is refused for spelling its
|
|
132
|
+
* field names the wrong way.
|
|
133
|
+
*
|
|
134
|
+
* It is the SDK's own verdict rather than a token the peer sent, which is the one place
|
|
135
|
+
* that happens: the peer's failure IS that it did not speak the contract, so there is no
|
|
136
|
+
* refusal of its own to carry. Go never produces it — it speaks binary proto, and
|
|
137
|
+
* protojson accepts both spellings on the way in — so this is a value only the JSON
|
|
138
|
+
* clients can observe, not a divergence in the shared failure taxonomy.
|
|
139
|
+
*/
|
|
140
|
+
export declare const NOT_CANONICAL_WIRE_NAMING = "not_canonical_wire_naming";
|
|
141
|
+
/**
|
|
142
|
+
* Whether a request is checked against its generated schema before it is signed and sent.
|
|
143
|
+
* "strict" is the default; a caller that means to probe a server with a deliberately
|
|
144
|
+
* invalid message turns it off.
|
|
145
|
+
*
|
|
146
|
+
* DELIBERATELY STRICTER THAN GO, which defaults to ValidationOff. These two SDKs are the
|
|
147
|
+
* ones handed to external partners, so catching a missing recipient or idempotency key
|
|
148
|
+
* before anything is signed is worth more here than matching the oracle's default. It
|
|
149
|
+
* costs nothing in safety: the Exchange enforces the same rules whatever this says, and
|
|
150
|
+
* the answer coming back is validated either way.
|
|
151
|
+
*
|
|
152
|
+
* And it is a SMALLER check than the Go client's, not an equal one: the generated schema
|
|
153
|
+
* carries field-level rules, while the cross-field CEL rules stay server-authoritative.
|
|
154
|
+
* "Strict" does not mean the same thing in the two places.
|
|
155
|
+
*/
|
|
156
|
+
export type Validation = "strict" | "off";
|
|
157
|
+
/**
|
|
158
|
+
* validateRequest refuses a request the protocol would reject anyway, before it costs a
|
|
159
|
+
* signature and a round trip.
|
|
160
|
+
*
|
|
161
|
+
* The check is FIELD-level, which is what the generated schema carries: the cross-field
|
|
162
|
+
* CEL rules stay server-authoritative, so this is a smaller check than the Go client's
|
|
163
|
+
* protovalidate interceptor, not an equal one. What it does catch is the common case — a
|
|
164
|
+
* required recipient or idempotency key left unset — where the server's only possible
|
|
165
|
+
* answer is a refusal.
|
|
166
|
+
*
|
|
167
|
+
* The validated value is DISCARDED and the caller's own object is what gets sent: a parse
|
|
168
|
+
* fills declared defaults, and sending those would put fields on the wire the caller never
|
|
169
|
+
* set.
|
|
170
|
+
*/
|
|
171
|
+
export declare function validateRequest(op: string, message: unknown, schema: {
|
|
172
|
+
safeParse: (v: unknown) => {
|
|
173
|
+
success: boolean;
|
|
174
|
+
data?: unknown;
|
|
175
|
+
};
|
|
176
|
+
}, validation: Validation): void;
|
|
177
|
+
/**
|
|
178
|
+
* parseMessage validates one answer against its generated schema, after refusing a
|
|
179
|
+
* non-canonical spelling. The schema owns the field names, the enum NAME strings and the
|
|
180
|
+
* proto3 omit-unpopulated shape; nothing about the message is decided here.
|
|
181
|
+
*/
|
|
182
|
+
export declare function parseMessage<T>(op: string, raw: unknown, schema: {
|
|
183
|
+
safeParse: (v: unknown) => {
|
|
184
|
+
success: boolean;
|
|
185
|
+
data?: unknown;
|
|
186
|
+
};
|
|
187
|
+
}): T;
|