@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,381 @@
|
|
|
1
|
+
// The Connect-unary JSON transport: POST /fora.v1.<Service>/<Method>, JSON body.
|
|
2
|
+
//
|
|
3
|
+
// Every FORA RPC is unary, so this is the whole protocol rather than a subset of it.
|
|
4
|
+
// Full Connect framing exists for streaming, which FORA has none of and would never use,
|
|
5
|
+
// and it would drag in a protobuf binary codec this SDK deliberately does not have — the
|
|
6
|
+
// Zod/Pydantic decision makes these clients JSON-only by design, not by omission.
|
|
7
|
+
//
|
|
8
|
+
// What this module owns: the URL, the header set, composing the signing face over the
|
|
9
|
+
// body bytes, the response-size bound, the deadline, refusing redirects, and turning a
|
|
10
|
+
// non-2xx into the client's typed failure. What it does NOT own: message shapes. Bodies
|
|
11
|
+
// are produced and parsed by the generated Zod schemas, which is the same path the
|
|
12
|
+
// canonical proto-JSON round-trip gate already proves loss-free against Go protojson.
|
|
13
|
+
import { signOutbound } from "../core/signing-transport.js";
|
|
14
|
+
import { errorDetailFrom } from "../src/errordetail.js";
|
|
15
|
+
import { MAX_BODY_DEPTH, rawNestingDepth } from "../src/jsondepth.js";
|
|
16
|
+
import { parseWire, WireNamingError } from "../../../gen/ts/wire/base.js";
|
|
17
|
+
import { requireScheme } from "../resolvers/http.js";
|
|
18
|
+
import { ConnectProtocolVersion, ConnectProtocolVersionHeader, ContentTypeJSON, RequestIDHeader, } from "../src/wire.js";
|
|
19
|
+
import { connectCodeFromStatus, kindOfConnectCode, malformed, ForaCallError, } from "./errors.js";
|
|
20
|
+
/**
|
|
21
|
+
* DEFAULT_MAX_RPC_READ_BYTES caps the response body a single FORA call will read.
|
|
22
|
+
*
|
|
23
|
+
* A FORA response for a realistic batch is small; the bound is what stops a peer —
|
|
24
|
+
* including one an offer named — spending the caller's memory on its behalf. Mirrors the
|
|
25
|
+
* Go client's DefaultMaxRPCReadBytes.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_MAX_RPC_READ_BYTES = 1 << 20; // 1 MiB
|
|
28
|
+
/**
|
|
29
|
+
* DEFAULT_CALL_TIMEOUT_MS bounds one call. A FORA RPC is interactive — something is
|
|
30
|
+
* waiting on the other end — so a request that has not answered by now is more useful as
|
|
31
|
+
* an error than as a hang. Mirrors the Go client's DefaultCallTimeout.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_CALL_TIMEOUT_MS = 30_000;
|
|
34
|
+
/**
|
|
35
|
+
* rpcURL joins an origin to the Connect unary path. The trailing slash is trimmed so a
|
|
36
|
+
* base URL written either way addresses the same method — a doubled slash is a different
|
|
37
|
+
* path to some servers and a 404 from them.
|
|
38
|
+
*/
|
|
39
|
+
export function rpcURL(target) {
|
|
40
|
+
// Joined through URL, not concatenated: a base carrying a query or a fragment would
|
|
41
|
+
// otherwise swallow the RPC path — "https://x.test?a=1" + "/fora.v1.…" leaves the path
|
|
42
|
+
// inside the query string, and the call reaches the origin's root.
|
|
43
|
+
const base = target.baseURL.replace(/\/+$/, "");
|
|
44
|
+
const path = `/${target.service}/${target.method}`;
|
|
45
|
+
try {
|
|
46
|
+
const url = new URL(base);
|
|
47
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`;
|
|
48
|
+
url.search = "";
|
|
49
|
+
url.hash = "";
|
|
50
|
+
return url.toString();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Not a URL this runtime can parse. Left to the send to refuse, which names the
|
|
54
|
+
// value; failing here would report it as something the caller could fix by
|
|
55
|
+
// changing the RPC instead of the address.
|
|
56
|
+
return `${base}${path}`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* unaryCall sends one FORA RPC and returns the peer's answer as parsed JSON.
|
|
61
|
+
*
|
|
62
|
+
* The body is serialized ONCE and the same bytes are signed and sent: RFC 9530
|
|
63
|
+
* Content-Digest covers the exact octets, so re-serializing between signing and sending
|
|
64
|
+
* would produce a digest for a body the peer never received.
|
|
65
|
+
*/
|
|
66
|
+
export async function unaryCall(opts) {
|
|
67
|
+
const url = rpcURL(opts.target);
|
|
68
|
+
// The scheme, before anything is built for this call. It sits ABOVE the send because
|
|
69
|
+
// `send` is an injectable option: a caller that supplies one replaces the dial, and a
|
|
70
|
+
// gate living inside the default send would leave with it. Go states the same rule the
|
|
71
|
+
// other way round — a caller hands over what sits UNDER the guard, never what replaces
|
|
72
|
+
// it — and the delivery leg already gates here rather than at its dispatcher.
|
|
73
|
+
//
|
|
74
|
+
// Converted HERE rather than left to the catch below, because it runs before the try:
|
|
75
|
+
// refusing early is the point — a URL this client will not dial should cost no body
|
|
76
|
+
// encoding, no timer and no signature. requireScheme raises the resolvers' own
|
|
77
|
+
// SsrfBlockedError, which is deliberate, and this tier is what gives it a class. Every
|
|
78
|
+
// verb throws ForaCallError and nothing else; an untyped throw here is one a caller
|
|
79
|
+
// branching on that contract drops silently.
|
|
80
|
+
if (opts.guarded === true) {
|
|
81
|
+
try {
|
|
82
|
+
requireScheme(url);
|
|
83
|
+
}
|
|
84
|
+
catch (cause) {
|
|
85
|
+
// Unreachable, matching what the delivery leg answers for the identical refusal
|
|
86
|
+
// and what Go answers when the same check fires inside its RoundTripper.
|
|
87
|
+
throw new ForaCallError({ kind: "unreachable", op: opts.op, cause });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const body = encodeBody(opts.op, opts.message);
|
|
91
|
+
const headers = {
|
|
92
|
+
"content-type": ContentTypeJSON,
|
|
93
|
+
[ConnectProtocolVersionHeader]: ConnectProtocolVersion,
|
|
94
|
+
...IDENTITY_ENCODING,
|
|
95
|
+
};
|
|
96
|
+
// Stamped BEFORE the signature so the covered headers are written last and nothing
|
|
97
|
+
// here can be mistaken for part of the proof. The correlation id is not covered and is
|
|
98
|
+
// not meant to be: it identifies the request in two sets of logs, it authorises
|
|
99
|
+
// nothing.
|
|
100
|
+
if (opts.requestId !== undefined)
|
|
101
|
+
headers[RequestIDHeader] = opts.requestId();
|
|
102
|
+
// The deadline COVERS the signature rather than starting after it. Signing may reach a
|
|
103
|
+
// custody backend, and a timer started afterwards would give the send a fresh full
|
|
104
|
+
// budget on top of whatever signing already spent — so "bounds one call" would mean
|
|
105
|
+
// something different depending on how slow custody was. It does not INTERRUPT signing:
|
|
106
|
+
// WebCrypto takes no signal, so what this bounds is the total, which is the property Go
|
|
107
|
+
// gets from passing one context through both. The content leg covers proof minting the
|
|
108
|
+
// same way, and Go pins that with a test of its own.
|
|
109
|
+
const controller = new AbortController();
|
|
110
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS);
|
|
111
|
+
let response;
|
|
112
|
+
try {
|
|
113
|
+
if (opts.signer !== undefined) {
|
|
114
|
+
Object.assign(headers, await signCall(opts.op, url, body, opts.signer));
|
|
115
|
+
}
|
|
116
|
+
response = await opts.send({
|
|
117
|
+
url,
|
|
118
|
+
headers,
|
|
119
|
+
body,
|
|
120
|
+
signal: controller.signal,
|
|
121
|
+
maxBytes: opts.maxBytes ?? DEFAULT_MAX_RPC_READ_BYTES,
|
|
122
|
+
op: opts.op,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (cause) {
|
|
126
|
+
throw asCallError(opts.op, cause);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
}
|
|
131
|
+
return decodeResponse(opts.op, response);
|
|
132
|
+
}
|
|
133
|
+
// encodeBody renders the message as the canonical proto-JSON bytes that get both signed
|
|
134
|
+
// and sent. The message is already a parsed generated-schema value, so JSON.stringify is
|
|
135
|
+
// the emission — no field names or enum spellings are decided here.
|
|
136
|
+
function encodeBody(op, message) {
|
|
137
|
+
let text;
|
|
138
|
+
try {
|
|
139
|
+
text = JSON.stringify(message ?? {});
|
|
140
|
+
}
|
|
141
|
+
catch (cause) {
|
|
142
|
+
throw malformed(op, cause);
|
|
143
|
+
}
|
|
144
|
+
return new TextEncoder().encode(text);
|
|
145
|
+
}
|
|
146
|
+
// signCall produces the RFC 9421 headers for this request. A custody failure is
|
|
147
|
+
// `not_signable`, matching what the content leg answers for the same missing holder: a
|
|
148
|
+
// caller branching on the kind sees one condition under one class, whichever verb met it
|
|
149
|
+
// first.
|
|
150
|
+
async function signCall(op, url, body, signer) {
|
|
151
|
+
try {
|
|
152
|
+
const signed = await signOutbound({
|
|
153
|
+
privKey: signer.privKey,
|
|
154
|
+
keyid: signer.keyid,
|
|
155
|
+
method: "POST",
|
|
156
|
+
url,
|
|
157
|
+
body,
|
|
158
|
+
authorization: "",
|
|
159
|
+
signatureAgent: signer.signatureAgent ?? "",
|
|
160
|
+
...(signer.window !== undefined ? { window: signer.window } : {}),
|
|
161
|
+
});
|
|
162
|
+
return signed.headers;
|
|
163
|
+
}
|
|
164
|
+
catch (cause) {
|
|
165
|
+
throw new ForaCallError({ kind: "not_signable", op, cause });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// asCallError classifies a failure the send raised. A ForaCallError from the send (the
|
|
169
|
+
// size cap, most often) is already classified and passes through; anything else is a peer
|
|
170
|
+
// that did not answer.
|
|
171
|
+
function asCallError(op, cause) {
|
|
172
|
+
if (cause instanceof ForaCallError)
|
|
173
|
+
return cause;
|
|
174
|
+
return new ForaCallError({ kind: "unreachable", op, cause });
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* IDENTITY_ENCODING asks the peer for no content coding.
|
|
178
|
+
*
|
|
179
|
+
* Sending nothing is not the same thing: per RFC 9110 §12.5.3 an absent Accept-Encoding
|
|
180
|
+
* means ANY coding is acceptable, and undici does not decode one — so a gzipped answer
|
|
181
|
+
* would arrive as raw octets, fail to parse, and be reported as the peer's fault for
|
|
182
|
+
* something the peer was entitled to do.
|
|
183
|
+
*/
|
|
184
|
+
export const IDENTITY_ENCODING = { "accept-encoding": "identity" };
|
|
185
|
+
/**
|
|
186
|
+
* refuseUnrequestedEncoding refuses a response carrying a content coding we did not ask for.
|
|
187
|
+
*
|
|
188
|
+
* Every leg negotiates identity, so a coding here is the peer answering that negotiation
|
|
189
|
+
* and then ignoring it. It is refused BEFORE the body is read, which is the only bound
|
|
190
|
+
* that holds at any chunk size — a decoder expands a whole raw read at once, so a running
|
|
191
|
+
* total over decoded chunks can be overshot by however much one chunk inflates to. The
|
|
192
|
+
* same check, for the same reason, is what bounds the Python client.
|
|
193
|
+
*
|
|
194
|
+
* `identity` itself is not a coding, and neither is an absent header.
|
|
195
|
+
*/
|
|
196
|
+
export function refuseUnrequestedEncoding(op, status, headers) {
|
|
197
|
+
const raw = headers["content-encoding"];
|
|
198
|
+
const coding = (Array.isArray(raw) ? raw.join(",") : (raw ?? "")).trim().toLowerCase();
|
|
199
|
+
if (coding === "" || coding === "identity")
|
|
200
|
+
return;
|
|
201
|
+
throw new ForaCallError({
|
|
202
|
+
kind: "malformed",
|
|
203
|
+
op,
|
|
204
|
+
status,
|
|
205
|
+
cause: new Error(`peer answered with content-encoding "${coding}" after being asked for identity; ` +
|
|
206
|
+
"a coding this client did not negotiate cannot be read under a bound"),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* decodeResponse turns one answer into a parsed message, or throws the typed failure.
|
|
211
|
+
*
|
|
212
|
+
* A non-2xx is the Connect error envelope: `{code, message, details}`. The typed reason
|
|
213
|
+
* rides in `details`, which errorDetailFrom reads — including the lowerCamelCase `debug`
|
|
214
|
+
* projection connect-go emits there and no server codec replaces.
|
|
215
|
+
*/
|
|
216
|
+
export function decodeResponse(op, response) {
|
|
217
|
+
// A 3xx before anything is read out of the body. Every leg refuses to follow a
|
|
218
|
+
// redirect, so one reaching here is a server that did not answer rather than one that
|
|
219
|
+
// declined — and there is nothing in a redirect body to interpret. Unconditional on
|
|
220
|
+
// purpose: a 302 carrying a Connect envelope would otherwise be read as the peer's own
|
|
221
|
+
// verdict, and connect-go has no answer to mirror here because its transport follows
|
|
222
|
+
// redirects and never surfaces one.
|
|
223
|
+
if (response.status >= 300 && response.status < 400) {
|
|
224
|
+
throw new ForaCallError({
|
|
225
|
+
kind: "unreachable",
|
|
226
|
+
op,
|
|
227
|
+
status: response.status,
|
|
228
|
+
cause: new Error("peer answered with a redirect, which this client does not follow"),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const payload = parseJSON(op, response);
|
|
232
|
+
if (response.status < 200 || response.status >= 300) {
|
|
233
|
+
throw connectEnvelopeError(op, response.status, payload);
|
|
234
|
+
}
|
|
235
|
+
return payload;
|
|
236
|
+
}
|
|
237
|
+
function parseJSON(op, response) {
|
|
238
|
+
// Depth BEFORE the parse, and the bound is a CONTRACT rather than a property of this
|
|
239
|
+
// runtime. V8's JSON parser is iterative, so nothing here overflows a stack the way
|
|
240
|
+
// Python's reader does — but "how deep may a document this client did not write be" has
|
|
241
|
+
// to be one answer for both JSON clients, or a peer's answer is readable in one and
|
|
242
|
+
// refused in the other, and nothing says which is right. Go is not bounded here and
|
|
243
|
+
// deliberately so; docs/design-history.md records the difference.
|
|
244
|
+
//
|
|
245
|
+
// The scan is lexical and shared with the registration-schema compiler, which reaches
|
|
246
|
+
// for it against a runtime that does overflow. Counting needs no recursion.
|
|
247
|
+
if (rawNestingDepth(response.body) > MAX_BODY_DEPTH) {
|
|
248
|
+
throw new ForaCallError({
|
|
249
|
+
kind: "malformed",
|
|
250
|
+
op,
|
|
251
|
+
status: response.status,
|
|
252
|
+
cause: new Error(`answer nests deeper than ${MAX_BODY_DEPTH} containers`),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
return JSON.parse(response.body === "" ? "{}" : response.body);
|
|
257
|
+
}
|
|
258
|
+
catch (cause) {
|
|
259
|
+
// A non-2xx body that is not JSON did not come from the service: it is a gateway, a
|
|
260
|
+
// proxy or a load balancer answering for it. The STATUS is then the only thing that
|
|
261
|
+
// classifies it, which is what connect-go does with the same answer — and calling a
|
|
262
|
+
// momentary 502 malformed would put a retryable outage in the "this peer is broken"
|
|
263
|
+
// class. A 2xx that is not JSON is a different thing: the service claimed to answer
|
|
264
|
+
// and did not, which IS malformed.
|
|
265
|
+
if (response.status < 200 || response.status >= 300) {
|
|
266
|
+
const code = connectCodeFromStatus(response.status);
|
|
267
|
+
throw new ForaCallError({
|
|
268
|
+
kind: kindOfConnectCode(code),
|
|
269
|
+
op,
|
|
270
|
+
status: response.status,
|
|
271
|
+
reason: code,
|
|
272
|
+
cause,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
throw new ForaCallError({
|
|
276
|
+
kind: "malformed",
|
|
277
|
+
op,
|
|
278
|
+
status: response.status,
|
|
279
|
+
cause,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function connectEnvelopeError(op, status, payload) {
|
|
284
|
+
const envelope = isRecord(payload) ? payload : {};
|
|
285
|
+
// An envelope carrying no code is not a verdict the peer reached, so the STATUS decides
|
|
286
|
+
// the class — which is what connect-go does with the same answer. Reporting a draining
|
|
287
|
+
// gateway's 503 as a refusal tells a caller not to retry a usage report that would
|
|
288
|
+
// succeed a moment later.
|
|
289
|
+
const named = typeof envelope["code"] === "string" ? envelope["code"] : "";
|
|
290
|
+
const code = named === "" ? connectCodeFromStatus(status) : named;
|
|
291
|
+
const detail = errorDetailFrom(envelope);
|
|
292
|
+
return new ForaCallError({
|
|
293
|
+
kind: kindOfConnectCode(code),
|
|
294
|
+
op,
|
|
295
|
+
status,
|
|
296
|
+
// The peer's own token, which is the Connect code here. A caller that wants more
|
|
297
|
+
// than the class reads the typed detail.
|
|
298
|
+
reason: code,
|
|
299
|
+
...(detail !== null ? { detail } : {}),
|
|
300
|
+
// The one site that fills peerMessage, because this is the one site holding a
|
|
301
|
+
// detail the PEER emitted. Every other CallError leaves it empty — including
|
|
302
|
+
// the content leg, whose detail this SDK writes itself.
|
|
303
|
+
...(detail?.message !== undefined && detail.message !== ""
|
|
304
|
+
? { peerMessage: detail.message }
|
|
305
|
+
: {}),
|
|
306
|
+
cause: typeof envelope["message"] === "string" ? envelope["message"] : undefined,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
function isRecord(v) {
|
|
310
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* NOT_CANONICAL_WIRE_NAMING is the reason a peer's answer is refused for spelling its
|
|
314
|
+
* field names the wrong way.
|
|
315
|
+
*
|
|
316
|
+
* It is the SDK's own verdict rather than a token the peer sent, which is the one place
|
|
317
|
+
* that happens: the peer's failure IS that it did not speak the contract, so there is no
|
|
318
|
+
* refusal of its own to carry. Go never produces it — it speaks binary proto, and
|
|
319
|
+
* protojson accepts both spellings on the way in — so this is a value only the JSON
|
|
320
|
+
* clients can observe, not a divergence in the shared failure taxonomy.
|
|
321
|
+
*/
|
|
322
|
+
export const NOT_CANONICAL_WIRE_NAMING = "not_canonical_wire_naming";
|
|
323
|
+
/**
|
|
324
|
+
* validateRequest refuses a request the protocol would reject anyway, before it costs a
|
|
325
|
+
* signature and a round trip.
|
|
326
|
+
*
|
|
327
|
+
* The check is FIELD-level, which is what the generated schema carries: the cross-field
|
|
328
|
+
* CEL rules stay server-authoritative, so this is a smaller check than the Go client's
|
|
329
|
+
* protovalidate interceptor, not an equal one. What it does catch is the common case — a
|
|
330
|
+
* required recipient or idempotency key left unset — where the server's only possible
|
|
331
|
+
* answer is a refusal.
|
|
332
|
+
*
|
|
333
|
+
* The validated value is DISCARDED and the caller's own object is what gets sent: a parse
|
|
334
|
+
* fills declared defaults, and sending those would put fields on the wire the caller never
|
|
335
|
+
* set.
|
|
336
|
+
*/
|
|
337
|
+
export function validateRequest(op, message, schema, validation) {
|
|
338
|
+
if (validation === "off")
|
|
339
|
+
return;
|
|
340
|
+
if (!parseUnderWirePolicy(op, message, schema).success) {
|
|
341
|
+
throw malformed(op, new Error("request failed its generated schema; the server could only refuse it"));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* parseMessage validates one answer against its generated schema, after refusing a
|
|
346
|
+
* non-canonical spelling. The schema owns the field names, the enum NAME strings and the
|
|
347
|
+
* proto3 omit-unpopulated shape; nothing about the message is decided here.
|
|
348
|
+
*/
|
|
349
|
+
export function parseMessage(op, raw, schema) {
|
|
350
|
+
const parsed = parseUnderWirePolicy(op, raw, schema);
|
|
351
|
+
if (!parsed.success) {
|
|
352
|
+
throw malformed(op, new Error("peer answer failed its generated schema"));
|
|
353
|
+
}
|
|
354
|
+
return parsed.data;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* parseUnderWirePolicy runs the generated schema seam and turns its one refusal into this
|
|
358
|
+
* tier's typed failure.
|
|
359
|
+
*
|
|
360
|
+
* The wire policy — a null means the field has no value, and the lowerCamelCase
|
|
361
|
+
* json_name alias is refused at every depth — belongs to the schemas, so it lives with
|
|
362
|
+
* them in gen/ts/wire/base.ts and this tier only names what a refusal MEANS to a caller.
|
|
363
|
+
* Every parse of a generated schema in this SDK goes through here; a bare `safeParse`
|
|
364
|
+
* would skip the policy, which the no-direct-parse guard is there to catch.
|
|
365
|
+
*/
|
|
366
|
+
function parseUnderWirePolicy(op, raw, schema) {
|
|
367
|
+
try {
|
|
368
|
+
return parseWire(schema, raw);
|
|
369
|
+
}
|
|
370
|
+
catch (cause) {
|
|
371
|
+
if (cause instanceof WireNamingError) {
|
|
372
|
+
throw new ForaCallError({
|
|
373
|
+
kind: "malformed",
|
|
374
|
+
op,
|
|
375
|
+
reason: NOT_CANONICAL_WIRE_NAMING,
|
|
376
|
+
cause,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
throw cause;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** One covered-component identifier: a lowercased name plus, for a forwarding
|
|
2
|
+
* chain link `"signature";key="sigN"`, the referenced predecessor label. */
|
|
3
|
+
export interface MultisigCovered {
|
|
4
|
+
name: string;
|
|
5
|
+
chainKey?: string;
|
|
6
|
+
}
|
|
7
|
+
/** One parsed Signature-Input dictionary member (one hop's label). `rawInner` is
|
|
8
|
+
* the VERBATIM value after `label=` — the exact @signature-params bytes the hop's
|
|
9
|
+
* signature base must terminate with (Go sigParams.RawInner). */
|
|
10
|
+
export interface MultisigMember {
|
|
11
|
+
label: string;
|
|
12
|
+
rawInner: string;
|
|
13
|
+
covered: MultisigCovered[];
|
|
14
|
+
keyid: string | null;
|
|
15
|
+
alg: string | null;
|
|
16
|
+
created?: number;
|
|
17
|
+
expires?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Split one SFV dictionary header value on TOP-LEVEL commas, honoring quoted
|
|
21
|
+
* strings and their backslash escapes (Go splitTopLevelMembers). A comma inside a
|
|
22
|
+
* quoted keyid must NOT tear the member in two.
|
|
23
|
+
*/
|
|
24
|
+
export declare function splitTopLevelMembers(s: string): string[];
|
|
25
|
+
/**
|
|
26
|
+
* The VERBATIM member value after `label=` for each label across the given
|
|
27
|
+
* Signature-Input header values (Go rawInnerByLabel). Later occurrences overwrite
|
|
28
|
+
* earlier ones, matching SFV dictionary last-wins semantics.
|
|
29
|
+
*/
|
|
30
|
+
export declare function rawInnerByLabel(values: string[]): Record<string, string>;
|
|
31
|
+
/**
|
|
32
|
+
* Full multi-label parse of the Signature-Input header values, preserving header
|
|
33
|
+
* order. Returns undefined (clean reject) on any malformed member — never a
|
|
34
|
+
* mis-slice into a bogus covered set.
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseMultisigSignatureInput(values: string[]): MultisigMember[] | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Parse the RFC 9421 `Signature` header (`label=:<std-base64>:, …`) into a
|
|
39
|
+
* label→raw-bytes map — the per-label lookup the forwarding-chain link resolution
|
|
40
|
+
* and the multisig verify loop share (Go signatureBytesForLabel / parseSigLabel).
|
|
41
|
+
*/
|
|
42
|
+
export declare function signatureBytesByLabel(sigHeader: string): Record<string, Uint8Array<ArrayBuffer>>;
|
|
43
|
+
/**
|
|
44
|
+
* The highest sigN label number present in a Signature-Input value (0 when none)
|
|
45
|
+
* — Go maxSignatureLabelN. Backs append's next-label / predecessor resolution.
|
|
46
|
+
*/
|
|
47
|
+
export declare function maxSigLabelN(signatureInput: string): number;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// sdk/ts multi-member RFC 8941 Signature-Input dictionary parser + verbatim
|
|
2
|
+
// inner-per-label extractor — the TS port of Go helpers.parseAllSignatures /
|
|
3
|
+
// rawInnerByLabel / splitTopLevelMembers (verify.go). The single-sig verify path
|
|
4
|
+
// (core/verify-request.ts) hand-rolls a minimal ONE-label parser; the multisig
|
|
5
|
+
// forwarding chain needs the FULL multi-member dictionary parse plus the
|
|
6
|
+
// exact byte-verbatim inner value each hop's base terminates with.
|
|
7
|
+
//
|
|
8
|
+
// Dependency-light on purpose: no new runtime dep, mirroring the existing minimal
|
|
9
|
+
// single-label parsers. Correctness on ADVERSARIAL headers (quoted-comma,
|
|
10
|
+
// backslash escapes, top-level splitting, malformed reject) is pinned by
|
|
11
|
+
// tests/multisig-parse.edge.test.ts — the canonical Go golden vectors are
|
|
12
|
+
// well-behaved and do NOT gate the parser.
|
|
13
|
+
import { decodeBase64Url } from "../src/base64url.js";
|
|
14
|
+
/**
|
|
15
|
+
* Split one SFV dictionary header value on TOP-LEVEL commas, honoring quoted
|
|
16
|
+
* strings and their backslash escapes (Go splitTopLevelMembers). A comma inside a
|
|
17
|
+
* quoted keyid must NOT tear the member in two.
|
|
18
|
+
*/
|
|
19
|
+
export function splitTopLevelMembers(s) {
|
|
20
|
+
const parts = [];
|
|
21
|
+
let start = 0;
|
|
22
|
+
let inQuote = false;
|
|
23
|
+
let escaped = false;
|
|
24
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
25
|
+
const c = s[i];
|
|
26
|
+
if (escaped) {
|
|
27
|
+
escaped = false;
|
|
28
|
+
}
|
|
29
|
+
else if (c === "\\" && inQuote) {
|
|
30
|
+
escaped = true;
|
|
31
|
+
}
|
|
32
|
+
else if (c === '"') {
|
|
33
|
+
inQuote = !inQuote;
|
|
34
|
+
}
|
|
35
|
+
else if (c === "," && !inQuote) {
|
|
36
|
+
parts.push(s.slice(start, i));
|
|
37
|
+
start = i + 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
parts.push(s.slice(start));
|
|
41
|
+
return parts;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The VERBATIM member value after `label=` for each label across the given
|
|
45
|
+
* Signature-Input header values (Go rawInnerByLabel). Later occurrences overwrite
|
|
46
|
+
* earlier ones, matching SFV dictionary last-wins semantics.
|
|
47
|
+
*/
|
|
48
|
+
export function rawInnerByLabel(values) {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const v of values) {
|
|
51
|
+
for (const member of splitTopLevelMembers(v)) {
|
|
52
|
+
const eq = member.indexOf("=");
|
|
53
|
+
if (eq <= 0)
|
|
54
|
+
continue;
|
|
55
|
+
const label = member.slice(0, eq).trim();
|
|
56
|
+
out[label] = member.slice(eq + 1).trim();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
// findQuoteEnd returns the index of the closing quote for the quoted string that
|
|
62
|
+
// opens at `open`, honoring backslash escapes, or -1 if unterminated.
|
|
63
|
+
function findQuoteEnd(s, open) {
|
|
64
|
+
let escaped = false;
|
|
65
|
+
for (let i = open + 1; i < s.length; i += 1) {
|
|
66
|
+
const c = s[i];
|
|
67
|
+
if (escaped) {
|
|
68
|
+
escaped = false;
|
|
69
|
+
}
|
|
70
|
+
else if (c === "\\") {
|
|
71
|
+
escaped = true;
|
|
72
|
+
}
|
|
73
|
+
else if (c === '"') {
|
|
74
|
+
return i;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return -1;
|
|
78
|
+
}
|
|
79
|
+
// findInnerListEnd returns the index of the inner-list closing paren, honoring
|
|
80
|
+
// quoted strings (a param value may contain a paren), or -1 if unterminated.
|
|
81
|
+
function findInnerListEnd(s) {
|
|
82
|
+
let inQuote = false;
|
|
83
|
+
let escaped = false;
|
|
84
|
+
for (let i = 1; i < s.length; i += 1) {
|
|
85
|
+
const c = s[i];
|
|
86
|
+
if (escaped) {
|
|
87
|
+
escaped = false;
|
|
88
|
+
}
|
|
89
|
+
else if (c === "\\" && inQuote) {
|
|
90
|
+
escaped = true;
|
|
91
|
+
}
|
|
92
|
+
else if (c === '"') {
|
|
93
|
+
inQuote = !inQuote;
|
|
94
|
+
}
|
|
95
|
+
else if (c === ")" && !inQuote) {
|
|
96
|
+
return i;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return -1;
|
|
100
|
+
}
|
|
101
|
+
// parseCovered parses an inner-list body ("c1" "c2";key="v" …) into ordered
|
|
102
|
+
// covered components. Returns undefined on a malformed token.
|
|
103
|
+
function parseCovered(inner) {
|
|
104
|
+
const items = [];
|
|
105
|
+
let i = 0;
|
|
106
|
+
while (i < inner.length) {
|
|
107
|
+
if (inner[i] === " ") {
|
|
108
|
+
i += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (inner[i] !== '"')
|
|
112
|
+
return undefined;
|
|
113
|
+
const nameEnd = findQuoteEnd(inner, i);
|
|
114
|
+
if (nameEnd < 0)
|
|
115
|
+
return undefined;
|
|
116
|
+
const comp = { name: inner.slice(i + 1, nameEnd).toLowerCase() };
|
|
117
|
+
i = nameEnd + 1;
|
|
118
|
+
while (inner[i] === ";") {
|
|
119
|
+
const next = parseComponentParam(inner, i, comp);
|
|
120
|
+
if (next < 0)
|
|
121
|
+
return undefined;
|
|
122
|
+
i = next;
|
|
123
|
+
}
|
|
124
|
+
items.push(comp);
|
|
125
|
+
}
|
|
126
|
+
return items;
|
|
127
|
+
}
|
|
128
|
+
// parseComponentParam parses one `;key="val"` component parameter starting at
|
|
129
|
+
// `pos` (the ';'), stamping a chainKey onto comp for key="…". Returns the index
|
|
130
|
+
// after the param, or -1 on malformed.
|
|
131
|
+
function parseComponentParam(inner, pos, comp) {
|
|
132
|
+
const eq = inner.indexOf("=", pos);
|
|
133
|
+
if (eq < 0 || inner[eq + 1] !== '"')
|
|
134
|
+
return -1;
|
|
135
|
+
const pname = inner.slice(pos + 1, eq);
|
|
136
|
+
const vEnd = findQuoteEnd(inner, eq + 1);
|
|
137
|
+
if (vEnd < 0)
|
|
138
|
+
return -1;
|
|
139
|
+
if (pname === "key")
|
|
140
|
+
comp.chainKey = inner.slice(eq + 2, vEnd);
|
|
141
|
+
return vEnd + 1;
|
|
142
|
+
}
|
|
143
|
+
function matchQuoted(s, re) {
|
|
144
|
+
const m = s.match(re);
|
|
145
|
+
return m ? (m[1] ?? "") : null;
|
|
146
|
+
}
|
|
147
|
+
function matchInt(s, re) {
|
|
148
|
+
const m = s.match(re);
|
|
149
|
+
return m ? Number(m[1]) : undefined;
|
|
150
|
+
}
|
|
151
|
+
// parseMember parses one `label=(inner);params` member into a MultisigMember, or
|
|
152
|
+
// undefined on a malformed inner list / member.
|
|
153
|
+
function parseMember(raw) {
|
|
154
|
+
const eq = raw.indexOf("=");
|
|
155
|
+
if (eq <= 0)
|
|
156
|
+
return undefined;
|
|
157
|
+
const label = raw.slice(0, eq).trim();
|
|
158
|
+
const rawInner = raw.slice(eq + 1).trim();
|
|
159
|
+
if (rawInner[0] !== "(")
|
|
160
|
+
return undefined;
|
|
161
|
+
const close = findInnerListEnd(rawInner);
|
|
162
|
+
if (close < 0)
|
|
163
|
+
return undefined;
|
|
164
|
+
const covered = parseCovered(rawInner.slice(1, close));
|
|
165
|
+
if (!covered)
|
|
166
|
+
return undefined;
|
|
167
|
+
const tail = rawInner.slice(close + 1);
|
|
168
|
+
const created = matchInt(tail, /;created=(\d+)/);
|
|
169
|
+
const expires = matchInt(tail, /;expires=(\d+)/);
|
|
170
|
+
return {
|
|
171
|
+
label,
|
|
172
|
+
rawInner,
|
|
173
|
+
covered,
|
|
174
|
+
keyid: matchQuoted(tail, /;keyid="([^"]*)"/),
|
|
175
|
+
alg: matchQuoted(tail, /;alg="([^"]*)"/),
|
|
176
|
+
...(created !== undefined ? { created } : {}),
|
|
177
|
+
...(expires !== undefined ? { expires } : {}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Full multi-label parse of the Signature-Input header values, preserving header
|
|
182
|
+
* order. Returns undefined (clean reject) on any malformed member — never a
|
|
183
|
+
* mis-slice into a bogus covered set.
|
|
184
|
+
*/
|
|
185
|
+
export function parseMultisigSignatureInput(values) {
|
|
186
|
+
const members = [];
|
|
187
|
+
for (const v of values) {
|
|
188
|
+
for (const raw of splitTopLevelMembers(v)) {
|
|
189
|
+
const member = parseMember(raw);
|
|
190
|
+
if (!member)
|
|
191
|
+
return undefined;
|
|
192
|
+
members.push(member);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return members.length > 0 ? members : undefined;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Parse the RFC 9421 `Signature` header (`label=:<std-base64>:, …`) into a
|
|
199
|
+
* label→raw-bytes map — the per-label lookup the forwarding-chain link resolution
|
|
200
|
+
* and the multisig verify loop share (Go signatureBytesForLabel / parseSigLabel).
|
|
201
|
+
*/
|
|
202
|
+
export function signatureBytesByLabel(sigHeader) {
|
|
203
|
+
const out = {};
|
|
204
|
+
for (const member of splitTopLevelMembers(sigHeader)) {
|
|
205
|
+
const eq = member.indexOf("=");
|
|
206
|
+
if (eq <= 0)
|
|
207
|
+
continue;
|
|
208
|
+
const label = member.slice(0, eq).trim();
|
|
209
|
+
const val = member.slice(eq + 1).trim();
|
|
210
|
+
const first = val.indexOf(":");
|
|
211
|
+
const last = val.lastIndexOf(":");
|
|
212
|
+
if (first < 0 || last <= first)
|
|
213
|
+
continue;
|
|
214
|
+
const bytes = decodeBase64Url(val.slice(first + 1, last));
|
|
215
|
+
if (bytes)
|
|
216
|
+
out[label] = bytes;
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* The highest sigN label number present in a Signature-Input value (0 when none)
|
|
222
|
+
* — Go maxSignatureLabelN. Backs append's next-label / predecessor resolution.
|
|
223
|
+
*/
|
|
224
|
+
export function maxSigLabelN(signatureInput) {
|
|
225
|
+
let max = 0;
|
|
226
|
+
for (const label of Object.keys(rawInnerByLabel([signatureInput]))) {
|
|
227
|
+
if (!label.startsWith("sig"))
|
|
228
|
+
continue;
|
|
229
|
+
const n = Number(label.slice(3));
|
|
230
|
+
if (Number.isInteger(n) && n > max)
|
|
231
|
+
max = n;
|
|
232
|
+
}
|
|
233
|
+
return max;
|
|
234
|
+
}
|