@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,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;