@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,740 @@
1
+ // The FORA client: the six verbs an agent needs, over the Connect-unary JSON transport.
2
+ //
3
+ // TS port of sdk/go/connect (Client + BrokerClient). The transports differ — Go keeps
4
+ // full connect-go, this speaks the unary JSON form — but that is an implementation
5
+ // difference, not an API difference: the verbs carry the same names and the same
6
+ // contracts, and the fail-closed offer verification is the SAME Verifier the core ships,
7
+ // never a second verification path.
8
+ //
9
+ // It owns NO state. The signer, the keys, the dialing seam, the endpoint resolver and the
10
+ // verification policy are all injected.
11
+ import { createVerifier, } from "../core/verifier.js";
12
+ import { clockWindow } from "../core/window.js";
13
+ import { fromWireOffer } from "../core/wire-canon.js";
14
+ import { signOfferAcceptance, signRequestAcceptance, ACCEPTANCE_SIGNATURE_ALGORITHM, } from "../src/acceptance.js";
15
+ import { registrationFailureDetail } from "../src/errordetail.js";
16
+ import { redactUserinfo } from "../src/host-ref.js";
17
+ import { isBareDomain } from "../src/hosts.js";
18
+ import { generateIdempotencyKey } from "../src/idempotency.js";
19
+ import { ProtocolVersion } from "../src/wire.js";
20
+ import { DiscoveryRequestSchema, DiscoveryResponseSchema, DisputeRequestSchema, DisputeResponseSchema, GetAccountStatusRequestSchema, GetAccountStatusResponseSchema, PushResourcesRequestSchema, PushResourcesResponseSchema, RefreshCatalogRequestSchema, RefreshCatalogResponseSchema, RegisterRequestSchema, RegisterResponseSchema, RemoveResourcesRequestSchema, RemoveResourcesResponseSchema, ResourceQuerySchema, ResourceResponseSchema, TransactionRequestSchema, TransactionResponseSchema, UsageReportSchema, UsageReportResponseSchema, } from "../../../gen/ts/wire/schemas.js";
21
+ import { fetchContent } from "./content.js";
22
+ import { malformed, notSent, ForaCallError } from "./errors.js";
23
+ import { checkRegistrationData } from "../src/regschema.js";
24
+ import { createWellKnownRequirementsReader, ExchangeNotPermitted, ManifestNotExchange, ManifestUnusable, } from "../resolvers/index.js";
25
+ import { isInvalidHostRefusal, vetExchangeEndpoint } from "./route.js";
26
+ import { createUnarySend } from "./send.js";
27
+ import { DEFAULT_CALL_TIMEOUT_MS, DEFAULT_MAX_RPC_READ_BYTES, parseMessage, unaryCall, validateRequest, } from "./transport.js";
28
+ const EXCHANGE_SERVICE = "fora.v1.ExchangeService";
29
+ const BROKER_SERVICE = "fora.v1.BrokerService";
30
+ const CATALOG_SERVICE = "fora.v1.CatalogService";
31
+ /** How long a delivery-fetch proof stays valid, in seconds.
32
+ *
33
+ * Short on purpose, and deliberately NOT the signed URL's own expiry, which can be hours:
34
+ * the proof covers only the method and the URL, so for as long as the window is open
35
+ * anyone who observes the request can repeat it. */
36
+ export const DEFAULT_PROOF_WINDOW_SEC = 30;
37
+ function resolve(opts) {
38
+ const now = opts.now ?? (() => Date.now());
39
+ const verifier = createVerifier(opts.verification ?? "strict", {
40
+ // Fail-closed by default: with no resolver injected nothing resolves, so every
41
+ // offer lands in `rejected` with a reason rather than being surfaced unchecked.
42
+ resolve: opts.resolveOfferKey ?? (async () => undefined),
43
+ now,
44
+ });
45
+ const signer = opts.signer === undefined
46
+ ? undefined
47
+ : {
48
+ privKey: opts.signer.privKey,
49
+ keyid: opts.signer.keyid,
50
+ ...(opts.signatureAgent !== undefined
51
+ ? { signatureAgent: opts.signatureAgent }
52
+ : {}),
53
+ ...(opts.signWindow !== undefined ? { window: opts.signWindow } : {}),
54
+ };
55
+ return {
56
+ opts,
57
+ verifier,
58
+ send: opts.send ?? createUnarySend({ guarded: false }),
59
+ guardedSend: opts.guardedSend ?? createUnarySend({ guarded: true }),
60
+ requirements: opts.registrationRequirements ?? createWellKnownRequirementsReader(),
61
+ signer,
62
+ };
63
+ }
64
+ // call is the one place a verb reaches the wire, so every leg carries the same header
65
+ // set, the same bound and the same deadline.
66
+ async function call(r, op, baseURL, service, method, message, guarded) {
67
+ return unaryCall({
68
+ target: { baseURL, service, method },
69
+ op,
70
+ message,
71
+ // The leg decides the dial AND the gate together, so the two cannot drift apart.
72
+ send: guarded ? r.guardedSend : r.send,
73
+ guarded,
74
+ ...(r.signer !== undefined ? { signer: r.signer } : {}),
75
+ ...(r.opts.requestId !== undefined ? { requestId: r.opts.requestId } : {}),
76
+ maxBytes: r.opts.maxRPCReadBytes ?? DEFAULT_MAX_RPC_READ_BYTES,
77
+ timeoutMs: r.opts.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS,
78
+ });
79
+ }
80
+ /**
81
+ * createClient builds a client against baseURL — the agent's HOME Exchange, the one its
82
+ * account lives on.
83
+ *
84
+ * Discovery and purchase go to baseURL. A usage report or a dispute does NOT: those reach
85
+ * the Exchange that ISSUED the offer, resolved per call from that Exchange's own
86
+ * manifest, over a separately guarded transport.
87
+ */
88
+ export function createClient(baseURL, options = {}) {
89
+ const r = resolve(options);
90
+ return {
91
+ discover: (query) => discover(r, baseURL, query),
92
+ execute: (offer, opts) => execute(r, baseURL, offer, opts ?? {}),
93
+ reportUsage: (report, opts) => reportUsage(r, report, opts ?? {}),
94
+ dispute: (request, opts) => dispute(r, request, opts ?? {}),
95
+ register: (request) => register(r, request),
96
+ getAccountStatus: (request) => getAccountStatus(r, request),
97
+ fetch: (signedURL) => fetchVerb(r, signedURL),
98
+ };
99
+ }
100
+ /**
101
+ * createBrokerClient builds a client against a Broker's base URL.
102
+ *
103
+ * A SEPARATE constructor rather than a second surface on the exchange client because the
104
+ * two speak to different parties. A Broker is not an Exchange: it fans a query out across
105
+ * Exchanges it knows and relays back what they offered, so its address is the Broker's,
106
+ * not any Exchange's. Hanging both off one base URL would mean one of the two was always
107
+ * pointed at the wrong party.
108
+ *
109
+ * It takes the same options, but only the ones a discovery call has any use for do
110
+ * anything, and two need care. A single pinned offer key is the wrong shape here: Broker
111
+ * fan-out returns offers minted by different Exchanges, so inject a resolver that
112
+ * resolves each issuing Exchange's own key. And `requester` is REQUIRED, not optional: a
113
+ * Broker resolves the calling agent from it and declines a request naming none, so
114
+ * resolve refuses locally rather than spending a round trip to be told.
115
+ */
116
+ export function createBrokerClient(baseURL, options = {}) {
117
+ const r = resolve(options);
118
+ return { resolve: (request) => brokerResolve(r, baseURL, request) };
119
+ }
120
+ // ---------------------------------------------------------------------------
121
+ // The verbs
122
+ // ---------------------------------------------------------------------------
123
+ /**
124
+ * discover issues DiscoverResources and returns one group per requested URI, each
125
+ * carrying the fail-closed {verified, rejected} split.
126
+ *
127
+ * EVERY returned offer is verified against the exchange offer-signing key before it is
128
+ * handed back. Neither an unverifiable nor a doctored offer is silently dropped — it
129
+ * lands in `rejected` with a reason. A URI the responder GROUPED and left empty keeps its
130
+ * group, carrying the typed reason, so a refusal is an answer rather than an absence.
131
+ *
132
+ * The query is CLONED before `ver` and the requester are filled in, so the message the
133
+ * caller built stays untouched — it crossed a module boundary as an argument, not as a
134
+ * buffer. Both are filled only when EMPTY: a value the caller set is theirs.
135
+ *
136
+ * `exchange` is NOT among them: the caller MUST set it to the bare host of the Exchange
137
+ * being queried, because the contract requires every addressed request to name its
138
+ * recipient. It is left to the caller rather than derived from the base URL on purpose —
139
+ * the point of the field is to state whom the SENDER meant, and a value the transport
140
+ * filled in from the address it was already dialling would restate the dial target
141
+ * instead of checking it.
142
+ */
143
+ async function discover(r, baseURL, query) {
144
+ const op = "discover";
145
+ const sent = stampDiscovery(op, query, r.opts.requester);
146
+ validateRequest(op, sent, ResourceQuerySchema, r.opts.validation ?? "strict");
147
+ const raw = await call(r, op, baseURL, EXCHANGE_SERVICE, "DiscoverResources", sent, false);
148
+ const msg = parseMessage(op, raw, ResourceResponseSchema);
149
+ return {
150
+ // The offers are read from the RAW answer, not the parsed one. A schema parse is
151
+ // the GATE — it proves the answer is well formed and that its field names are
152
+ // canonical — but it also NORMALIZES: Zod fills every declared default, which adds
153
+ // keys the signer never covered and would make a genuine offer fail verification.
154
+ // A signature covers what the responder sent.
155
+ groups: await discoveredGroups(r.verifier, sent, isRecord(raw) ? raw : {}),
156
+ exchange: typeof msg["exchange"] === "string" ? msg["exchange"] : "",
157
+ ...(isRecord(msg["rate_limit"]) ? { rateLimit: msg["rate_limit"] } : {}),
158
+ };
159
+ }
160
+ /**
161
+ * discoveredGroups folds a ResourceResponse's two offer representations into the per-URI
162
+ * form.
163
+ *
164
+ * The message carries a grouped list AND a flat one, and the contract says a responder
165
+ * populating groups SHOULD leave the flat list empty "to avoid ambiguity" — but a real
166
+ * Exchange populates both, the flat list mirroring the grouped offers as a single-URI
167
+ * convenience. So the two are read as ALTERNATIVES, never concatenated: concatenating
168
+ * would double every offer against such a server, and deduplicating would silently accept
169
+ * a responder whose two lists disagree, which is precisely the ambiguity the contract
170
+ * forbids.
171
+ *
172
+ * Groups win when present. The flat fallback becomes a single group; it carries no URI of
173
+ * its own, so it takes the query's only URI when the query named exactly one, and none
174
+ * otherwise — the SDK does not invent an attribution the wire did not make.
175
+ */
176
+ async function discoveredGroups(verifier, query, msg) {
177
+ const groups = msg["offer_groups"];
178
+ if (Array.isArray(groups) && groups.length > 0) {
179
+ return verifier.sortGroups(groups.map(canonicalizeGroupOffers));
180
+ }
181
+ const flat = msg["offers"];
182
+ if (!Array.isArray(flat) || flat.length === 0)
183
+ return [];
184
+ const uris = query["uris"];
185
+ const uri = Array.isArray(uris) && uris.length === 1 && typeof uris[0] === "string"
186
+ ? uris[0]
187
+ : "";
188
+ return [
189
+ { uri, result: await verifier.sort(canonicalize(flat)), restrictionFilters: [] },
190
+ ];
191
+ }
192
+ /**
193
+ * canonicalize inverts the wire emission of each offer before it is verified.
194
+ *
195
+ * A FORA Exchange serves proto-JSON with EmitUnpopulated, so a wire offer carries
196
+ * zero-valued scalars, empty repeateds, null messages and *_UNSPECIFIED enums that the
197
+ * SIGNED form does not — the signature covers the omit-unpopulated rendering. Verifying
198
+ * the wire object as-is would fail every genuine offer, which is a fail-closed direction
199
+ * but the wrong answer. fromWireOffer is the schema-aware inversion, byte-parity-pinned
200
+ * against the Go oracle; a field newer than its pinned schema is kept verbatim, so an
201
+ * offer this SDK cannot reconstruct still verifies FALSE rather than being waved through.
202
+ *
203
+ * The verified value is therefore the CANONICAL offer, which is what execute reflects
204
+ * back: the Exchange verifies the presented bytes and re-renders them canonically either
205
+ * way, so reflecting the canonical form is the same statement with none of the wire
206
+ * emission's noise.
207
+ */
208
+ function canonicalize(offers) {
209
+ return offers.map((offer) => isRecord(offer) ? fromWireOffer(offer) : offer);
210
+ }
211
+ /** canonicalizeGroupOffers applies the inversion to one group's offers, leaving the
212
+ * group's own URI and typed reasons untouched. */
213
+ function canonicalizeGroupOffers(group) {
214
+ if (!isRecord(group))
215
+ return group;
216
+ const offers = group["offers"];
217
+ if (!Array.isArray(offers))
218
+ return group;
219
+ return { ...group, offers: canonicalize(offers) };
220
+ }
221
+ /**
222
+ * resolve runs discovery through the Broker, which fans out to the Exchanges it knows and
223
+ * returns one group per requested URI.
224
+ *
225
+ * Every returned offer is verified through the SAME fail-closed Verifier discover uses —
226
+ * not a second verification path. Broker-relayed offers are precisely the case that rule
227
+ * exists for: the Broker forwards offers it did not mint, and an unverified relay can
228
+ * steer an agent's selection with doctored terms that only fail later, at the purchase.
229
+ *
230
+ * A resolve that finds nothing is a SUCCESSFUL answer carrying a typed reason, not a
231
+ * failure: the whole-call reason lands on the result and the per-URI ones on each group.
232
+ *
233
+ * It carries no idempotency key. Pure discovery buys nothing and changes nothing, so
234
+ * there is nothing for a server to deduplicate — the request message has no such field.
235
+ */
236
+ async function brokerResolve(r, baseURL, request) {
237
+ const op = "resolve";
238
+ const sent = stampDiscovery(op, request, r.opts.requester);
239
+ // Refused locally rather than sent: a Broker resolves the calling agent from the
240
+ // requester and declines a request that names none, so this is a verdict the client
241
+ // already knows, and naming the remedy beats relaying "requester required" from a
242
+ // round trip away. execute refuses the same way.
243
+ if (sent["requester"] === undefined) {
244
+ throw malformed(op, new Error("no requester configured; a Broker resolves who is asking"));
245
+ }
246
+ validateRequest(op, sent, DiscoveryRequestSchema, r.opts.validation ?? "strict");
247
+ const raw = await call(r, op, baseURL, BROKER_SERVICE, "Resolve", sent, false);
248
+ const msg = parseMessage(op, raw, DiscoveryResponseSchema);
249
+ // Read from the RAW answer for the same reason discover does: a parse normalizes, and
250
+ // a signature covers what the responder sent.
251
+ const groups = isRecord(raw) ? raw["offer_groups"] : undefined;
252
+ const absence = msg["absence_reason"];
253
+ return {
254
+ groups: await r.verifier.sortGroups(Array.isArray(groups) ? groups.map(canonicalizeGroupOffers) : []),
255
+ ...(typeof absence === "string" ? { absenceReason: absence } : {}),
256
+ // A DiscoveryResponse names no single Exchange and carries no rate-limit signal —
257
+ // each offer carries its own issuing domain instead.
258
+ exchange: "",
259
+ };
260
+ }
261
+ /**
262
+ * execute commits to a VERIFIED offer and returns the transaction response.
263
+ *
264
+ * It accepts ONLY a VerifiedOffer — the brand is module-private to the core, so passing a
265
+ * rejected offer or a raw parsed one is a COMPILE error. A per-call idempotency key is
266
+ * minted fresh unless one is pinned. execute builds the whole TransactionRequest, so it
267
+ * also stamps `ver` from ProtocolVersion — the caller neither supplies nor overrides it.
268
+ */
269
+ async function execute(r, baseURL, offer, opts) {
270
+ const op = "execute";
271
+ if (r.opts.requester === undefined) {
272
+ throw malformed(op, new Error("no requester configured; an Exchange resolves who is buying from it"));
273
+ }
274
+ if (r.opts.signer === undefined) {
275
+ // not_signable, matching what fetch answers for the same missing holder: a caller
276
+ // branching on the kind sees one condition under one class, whichever verb met it
277
+ // first.
278
+ throw new ForaCallError({
279
+ kind: "not_signable",
280
+ op,
281
+ cause: new Error("no signer configured; a purchase carries a detached acceptance signed with the agent's own key"),
282
+ });
283
+ }
284
+ const wire = offer.offer;
285
+ const offerSig = typeof wire["signature"] === "string" ? wire["signature"] : "";
286
+ // An acceptance floating free of a concrete offer is meaningless, and an unsigned
287
+ // offer is reachable here: verification "off" and RejectedOffer.unsafe() both mint a
288
+ // VerifiedOffer without a signature check.
289
+ if (offerSig === "") {
290
+ throw malformed(op, new Error("cannot accept an unsigned offer"));
291
+ }
292
+ // `??` would take an EMPTY pinned key as a value and send it, which fails the
293
+ // message's own min(1). An empty string is the absence of a key, as Go and Python
294
+ // both read it.
295
+ const key = opts.idempotencyKey !== undefined && opts.idempotencyKey !== ""
296
+ ? opts.idempotencyKey
297
+ : generateIdempotencyKey();
298
+ const requester = r.opts.requester;
299
+ const requesterId = stringField(requester, "id");
300
+ const requesterDomain = stringField(requester, "domain");
301
+ const requestItems = [{ offerSig, exchange: stringField(wire, "exchange") }];
302
+ // The acceptance covers the offer, the requester and the idempotency key, so a retry
303
+ // that pins the same key reproduces byte-identical acceptance bytes. That is the
304
+ // deliberate-replay semantic, not an accident.
305
+ let signature;
306
+ try {
307
+ signature = await signOfferAcceptance({
308
+ offerSig,
309
+ requesterId,
310
+ requesterDomain,
311
+ idempotencyKey: key,
312
+ }, r.opts.signer.privKey);
313
+ }
314
+ catch (cause) {
315
+ throw new ForaCallError({ kind: "not_signable", op, cause });
316
+ }
317
+ let requestSignature;
318
+ if (requestItems[0].exchange !== "") {
319
+ try {
320
+ requestSignature = await signRequestAcceptance({ items: requestItems, requesterId, requesterDomain, idempotencyKey: key }, r.opts.signer.privKey);
321
+ }
322
+ catch (cause) {
323
+ throw new ForaCallError({ kind: "not_signable", op, cause });
324
+ }
325
+ }
326
+ // Items-only wire shape: a single offer is the degenerate 1-element items list, each
327
+ // item reflecting its signed Offer back exactly as received at discovery. The
328
+ // authoritative identity is the reflected offer; the optional top-level offer_id
329
+ // correlation scalar is left unset.
330
+ const request = {
331
+ ver: ProtocolVersion,
332
+ idempotency_key: key,
333
+ requester,
334
+ items: [
335
+ {
336
+ offer: wire,
337
+ agent_acceptance: {
338
+ signature,
339
+ signature_algorithm: ACCEPTANCE_SIGNATURE_ALGORITHM,
340
+ },
341
+ },
342
+ ],
343
+ ...(requestSignature === undefined
344
+ ? {} : { agent_request_acceptance: {
345
+ payload: {
346
+ items: requestItems.map((item) => ({
347
+ offer_sig: item.offerSig,
348
+ exchange: item.exchange,
349
+ })),
350
+ requester_id: requesterId,
351
+ requester_domain: requesterDomain,
352
+ idempotency_key: key,
353
+ },
354
+ signature: requestSignature,
355
+ signature_algorithm: ACCEPTANCE_SIGNATURE_ALGORITHM,
356
+ } }),
357
+ };
358
+ validateRequest(op, request, TransactionRequestSchema, r.opts.validation ?? "strict");
359
+ const raw = await call(r, op, baseURL, EXCHANGE_SERVICE, "ExecuteTransaction", request, false);
360
+ return parseMessage(op, raw, TransactionResponseSchema);
361
+ }
362
+ /**
363
+ * reportUsage files a usage report with the Exchange that ISSUED the offer — never
364
+ * through a Broker, and never to an address from configuration.
365
+ *
366
+ * The destination comes off the report itself: `exchange` carries the offer's signed
367
+ * exchange domain, and the endpoint is then resolved from that Exchange's own well-known
368
+ * manifest. Reading it off the message rather than taking it as an argument is what makes
369
+ * the rule structural — there is no parameter a configured origin could be passed as, so
370
+ * it cannot become the default by anyone's convenience.
371
+ *
372
+ * The report is CLONED before `ver` and the idempotency key are stamped, so the message
373
+ * the caller built stays untouched. The key identifies the REPORT, not the attempt: a
374
+ * fresh one is minted only when the caller supplied none, because an application that
375
+ * mints its own key for its own dedup would otherwise have it silently discarded and see
376
+ * every retry counted as a second report.
377
+ */
378
+ async function reportUsage(r, report, opts) {
379
+ const op = "report usage";
380
+ const sent = stampEnvelope(op, report, opts);
381
+ // The address is vetted BEFORE the schema: an unroutable recipient is a refusal to
382
+ // send, which is a different verdict from a message the server would reject, and the
383
+ // caller acts on them differently.
384
+ const endpoint = await vetExchangeEndpoint(r.opts.endpointResolver, stringField(sent, "exchange"), op);
385
+ validateRequest(op, sent, UsageReportSchema, r.opts.validation ?? "strict");
386
+ const raw = await call(r, op, endpoint, EXCHANGE_SERVICE, "ReportUsage", sent, true);
387
+ return parseMessage(op, raw, UsageReportResponseSchema);
388
+ }
389
+ /**
390
+ * dispute files a dispute with the Exchange that issued the offer, over the same vetted
391
+ * routing a usage report takes.
392
+ *
393
+ * The destination comes off the request, exactly as it does for a usage report. A
394
+ * parameter is something a configured origin can be passed as; reading the destination
395
+ * off the signed message leaves no such seam.
396
+ *
397
+ * The dispute chain is a structural invariant: an agent must have filed a usage report
398
+ * and received a report_id before it can dispute, so `report_id` and `transaction_id`
399
+ * both name links the Exchange already holds.
400
+ */
401
+ async function dispute(r, request, opts) {
402
+ const op = "dispute";
403
+ const sent = stampEnvelope(op, request, opts);
404
+ const endpoint = await vetExchangeEndpoint(r.opts.endpointResolver, stringField(sent, "exchange"), op);
405
+ validateRequest(op, sent, DisputeRequestSchema, r.opts.validation ?? "strict");
406
+ const raw = await call(r, op, endpoint, EXCHANGE_SERVICE, "DisputeTransaction", sent, true);
407
+ return parseMessage(op, raw, DisputeResponseSchema);
408
+ }
409
+ // ---------------------------------------------------------------------------
410
+ // The account-setup verbs
411
+ //
412
+ // They route like a usage report, not like discovery. An account is per-Exchange,
413
+ // and which Exchange is the agent's choice PER CALL: a target routinely arrives at
414
+ // runtime — a denial names where to register — rather than from configuration. So
415
+ // the destination is read off the request's own `exchange` field and resolved
416
+ // through that Exchange's own manifest, over the guarded leg.
417
+ //
418
+ // Neither message carries an idempotency key, so neither verb takes CallOptions.
419
+ // ---------------------------------------------------------------------------
420
+ /** The ErrorDetail domain for a refusal THIS CLIENT computed, before anything was
421
+ * sent. It names the failing surface, which here is the client's own tier: the
422
+ * Exchange never saw the request, so naming it would attribute a local verdict to a
423
+ * party that reached none. The naming rule the value follows — a Service suffix for
424
+ * an RPC service that exists in the contract, a bare noun for a tier that does not —
425
+ * is recorded on the Go oracle's edgeErrorDomain, beside EDGE_ERROR_DOMAIN's twin. */
426
+ const CLIENT_ERROR_DOMAIN = "fora.v1.Client";
427
+ /**
428
+ * register creates the calling agent's account at the Exchange the request names.
429
+ *
430
+ * The caller's identity is the request SIGNATURE. Nothing in the message says who is
431
+ * registering, and the business payload is not an identity claim.
432
+ *
433
+ * Four bounds on `registration_data` are checked before anything is signed, in the order
434
+ * the contract fixes, because a limit that exists to stop work belongs before the work it
435
+ * would stop — including before the manifest read.
436
+ *
437
+ * `terms_digest` is filled only when the caller left it ABSENT, from a freshly fetched
438
+ * manifest, and the payload is pre-checked against the schema that manifest publishes. A
439
+ * caller that sets the field is managing its own requirements and gets neither.
440
+ *
441
+ * A schema this SDK refuses never becomes a local veto: refusing locally and declining to
442
+ * send would turn a rule about reading a third party's document into a denial of service
443
+ * against the caller's own user, so an unusable schema is skipped and the Exchange
444
+ * decides. A usable schema the payload fails is the pre-check working, and that request is
445
+ * refused here with the offending members named.
446
+ */
447
+ async function register(r, request) {
448
+ const op = "register";
449
+ const sent = stampVer(op, request);
450
+ requireRecipient(op, stringField(sent, "exchange"));
451
+ // Narrowed rather than asserted. The bounds below are defined over an OBJECT, and
452
+ // Object.keys on a string answers its character indices — so a cast let a string
453
+ // payload be refused as "too many members", a verdict about a bound it never
454
+ // reached and a member count it does not have. Go cannot express the state at all
455
+ // (the field is a Struct) and Python narrows the same way, so this is the port
456
+ // that had to say so.
457
+ const verdict = checkRegistrationData(isRecord(sent.registration_data) ? sent.registration_data : null);
458
+ if (verdict !== "accepted") {
459
+ throw malformed(op, new Error(`registration_data: ${verdict}`));
460
+ }
461
+ if (sent.terms_digest === undefined || sent.terms_digest === null) {
462
+ await applyRegistrationRequirements(r, op, sent);
463
+ }
464
+ const endpoint = await vetExchangeEndpoint(r.opts.endpointResolver, stringField(sent, "exchange"), op);
465
+ validateRequest(op, sent, RegisterRequestSchema, r.opts.validation ?? "strict");
466
+ const raw = await call(r, op, endpoint, EXCHANGE_SERVICE, "Register", sent, true);
467
+ return parseMessage(op, raw, RegisterResponseSchema);
468
+ }
469
+ /**
470
+ * getAccountStatus reports whether the calling agent's account at the named Exchange is
471
+ * active.
472
+ *
473
+ * The request carries no field identifying the caller — the Exchange resolves the account
474
+ * from the verified signature — so `exchange` is the only thing that says which account is
475
+ * being asked about. An empty `billing_ref` in the answer is a NORMAL answer: no account
476
+ * there yet.
477
+ *
478
+ * A caveat worth knowing before calling this in a loop. The request has no varying field,
479
+ * so two calls to the same Exchange inside one wall-clock second sign IDENTICAL bytes, and
480
+ * a peer screening replays on (key id, signature) refuses the second. This verb does not
481
+ * choose the freshness window for you, because a window is one instance per client rather
482
+ * than per call: pass `monotonicWindow` as `signWindow` when repeat calls are expected.
483
+ */
484
+ async function getAccountStatus(r, request) {
485
+ const op = "get account status";
486
+ const sent = stampVer(op, request);
487
+ requireRecipient(op, stringField(sent, "exchange"));
488
+ const endpoint = await vetExchangeEndpoint(r.opts.endpointResolver, stringField(sent, "exchange"), op);
489
+ validateRequest(op, sent, GetAccountStatusRequestSchema, r.opts.validation ?? "strict");
490
+ const raw = await call(r, op, endpoint, EXCHANGE_SERVICE, "GetAccountStatus", sent, true);
491
+ return parseMessage(op, raw, GetAccountStatusResponseSchema);
492
+ }
493
+ /**
494
+ * applyRegistrationRequirements reads what the Exchange asks of a registration and applies
495
+ * it to the request being built.
496
+ *
497
+ * A failed READ refuses the registration rather than sending without a digest. Guessing
498
+ * here is not the cautious option: an Exchange that publishes a digest refuses a
499
+ * registration that omits one, so sending anyway trades a local failure the caller can act
500
+ * on for a remote one it cannot.
501
+ */
502
+ async function applyRegistrationRequirements(r, op, sent) {
503
+ let reqs;
504
+ try {
505
+ reqs = await r.requirements.resolveRegistrationRequirements(stringField(sent, "exchange"));
506
+ }
507
+ catch (err) {
508
+ // A value this deployment or the Exchange refused is FINAL; anything else is a
509
+ // transport failure worth retrying. The same split the routing tier makes, and
510
+ // the same causes: a value that is not a host will not become one on a later
511
+ // attempt either, and a document that arrived unusable arrives unusable again.
512
+ // The SDK's own reader throws all three itself — the middle two for the document
513
+ // it was handed, and ManifestUnusable for a version it cannot classify — and an
514
+ // INJECTED reader stricter than it reaches the same three. Only the invalid-host
515
+ // refusal is normally out of reach here, because the verb's own recipient check
516
+ // runs that rule first. Classifying any of them as retryable would have a caller
517
+ // retry a verdict.
518
+ if (err instanceof ExchangeNotPermitted ||
519
+ err instanceof ManifestNotExchange ||
520
+ err instanceof ManifestUnusable ||
521
+ isInvalidHostRefusal(err)) {
522
+ throw notSent(op, err);
523
+ }
524
+ throw new ForaCallError({ kind: "unreachable", op, cause: err });
525
+ }
526
+ if (reqs.termsDigest !== undefined) {
527
+ sent.terms_digest = reqs.termsDigest;
528
+ }
529
+ // A null validator means "nothing to enforce", which is the behaviour the contract
530
+ // requires both when the Exchange publishes no schema and when it publishes one this
531
+ // SDK refused. One branch, deliberately.
532
+ const fails = reqs.schema?.validate(sent.registration_data ?? {}) ?? [];
533
+ if (fails.length > 0) {
534
+ // An empty path addresses the whole object, which is how a missing required
535
+ // member and every other whole-object failure is reported. Rendering a bare
536
+ // ": ..." there would read as a member with no name.
537
+ const named = fails.map((f) => (f.path ? `${f.path}: ${f.error}` : f.error)).join("; ");
538
+ // The failures travel as a typed detail, not only as prose. An Exchange attaches
539
+ // this same list when it refuses the same payload, so a consumer that renders one
540
+ // refusal renders both, and nothing has to parse the members back out of a
541
+ // sentence.
542
+ throw new ForaCallError({
543
+ kind: "malformed",
544
+ op,
545
+ cause: new Error(`registration_data does not match the schema ${stringField(sent, "exchange")} publishes: ${named}`),
546
+ detail: registrationFailureDetail(CLIENT_ERROR_DOMAIN, "registration_data does not match the published data_schema", "REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA", fails),
547
+ });
548
+ }
549
+ }
550
+ /**
551
+ * fetch retrieves the content a signed delivery URL names, presenting proof of possession
552
+ * of the agent key that URL is bound to.
553
+ *
554
+ * This is the LOW-TIER fetch: follow one signed URL, present the key, return the bytes. It
555
+ * does not discover, select, buy or report — that orchestration is a separate, higher
556
+ * tier.
557
+ *
558
+ * It takes no CallOptions: a fetch is a GET against an already-issued URL, so there is no
559
+ * idempotency key to pin — nothing on this path mutates state.
560
+ */
561
+ async function fetchVerb(r, signedURL) {
562
+ const op = "fetch content";
563
+ if (r.opts.signer === undefined) {
564
+ throw new ForaCallError({
565
+ kind: "not_signable",
566
+ op,
567
+ cause: new Error("no signer configured; a bound fetch proves possession of the agent key — " +
568
+ "the same key the request is signed with"),
569
+ });
570
+ }
571
+ if (r.opts.agentPublicKey === undefined) {
572
+ throw new ForaCallError({
573
+ kind: "not_signable",
574
+ op,
575
+ cause: new Error("no agent public key configured; a bound fetch presents it alongside the " +
576
+ "proof, and a non-extractable signing key cannot yield it"),
577
+ });
578
+ }
579
+ return fetchContent(signedURL, {
580
+ // One private key, held by the signer. The public half rides alongside because
581
+ // custody keeps the private one and a CryptoKey cannot be asked for its pair.
582
+ keyPair: { privateKey: r.opts.signer.privKey, publicKey: r.opts.agentPublicKey },
583
+ // The proof window is the client's, not the signer's. core/sign.ts defaults to the
584
+ // 10-minute TTL a server-side proof uses; a delivery proof is minted for one GET
585
+ // and wants the short window instead, so the default is set here rather than
586
+ // inherited.
587
+ window: r.opts.proofWindow ?? clockWindow(() => Date.now() / 1000, DEFAULT_PROOF_WINDOW_SEC),
588
+ ...(r.opts.contentTimeoutMs !== undefined
589
+ ? { timeoutMs: r.opts.contentTimeoutMs }
590
+ : {}),
591
+ ...(r.opts.maxContentBytes !== undefined
592
+ ? { maxBytes: r.opts.maxContentBytes }
593
+ : {}),
594
+ ...(r.opts.requestId !== undefined ? { requestId: r.opts.requestId } : {}),
595
+ });
596
+ }
597
+ // ---------------------------------------------------------------------------
598
+ // Envelope stamping
599
+ // ---------------------------------------------------------------------------
600
+ /**
601
+ * stampDiscovery fills the envelope a DISCOVERY call carries, which is the mutating
602
+ * envelope minus the idempotency key: pure discovery buys nothing and changes nothing, so
603
+ * there is no action for a key to identify.
604
+ *
605
+ * Both fills are only-when-empty. The caller's own value always wins — the message
606
+ * crossed a module boundary as an argument, not as a buffer to fill in — and the
607
+ * requester is filled because both reference services resolve the calling agent from it
608
+ * and refuse a request that names none, while the client already holds that identity.
609
+ */
610
+ function stampDiscovery(op, message, requester) {
611
+ const sent = clone(op, message);
612
+ if (sent["ver"] === undefined || sent["ver"] === "")
613
+ sent["ver"] = ProtocolVersion;
614
+ if (sent["requester"] === undefined && requester !== undefined) {
615
+ sent["requester"] = requester;
616
+ }
617
+ return sent;
618
+ }
619
+ /**
620
+ * stampEnvelope fills the two envelope fields the protocol requires on a state-mutating
621
+ * call, WITHOUT overwriting what the caller already set.
622
+ *
623
+ * Fill-when-empty is the whole rule. `ver` has a single owner, so the SDK supplies it
624
+ * rather than making every caller reach for the constant. The idempotency key is REQUIRED
625
+ * and identifies the action rather than the attempt, so a value the caller put there is
626
+ * theirs — discarding it would turn each of their retries into a fresh action, which is
627
+ * the double-counting the field exists to prevent. A pinned key overrides both.
628
+ */
629
+ function stampEnvelope(op, message, opts) {
630
+ const sent = clone(op, message);
631
+ if (sent["ver"] === undefined || sent["ver"] === "")
632
+ sent["ver"] = ProtocolVersion;
633
+ const onMessage = sent["idempotency_key"];
634
+ // Each fallback is taken when the one before it is EMPTY, not merely absent: an empty
635
+ // pinned key is no key, which is how Go and Python both read it.
636
+ sent["idempotency_key"] =
637
+ opts.idempotencyKey !== undefined && opts.idempotencyKey !== ""
638
+ ? opts.idempotencyKey
639
+ : typeof onMessage === "string" && onMessage !== ""
640
+ ? onMessage
641
+ : generateIdempotencyKey();
642
+ return sent;
643
+ }
644
+ // clone copies a caller's message so the SDK can stamp its envelope without touching what
645
+ // the caller still holds. structuredClone is the runtime's own deep copy; a message that
646
+ // cannot survive it is one that cannot be serialized to the wire either.
647
+ function clone(op, message) {
648
+ try {
649
+ return structuredClone(message);
650
+ }
651
+ catch (cause) {
652
+ throw malformed(op, cause);
653
+ }
654
+ }
655
+ function stringField(record, key) {
656
+ const value = record[key];
657
+ return typeof value === "string" ? value : "";
658
+ }
659
+ function isRecord(v) {
660
+ return typeof v === "object" && v !== null && !Array.isArray(v);
661
+ }
662
+ /**
663
+ * createCatalogClient builds a client against an Exchange's CATALOG endpoint — the
664
+ * publisher role's face: push, remove and refresh the catalog entries a publisher, or a
665
+ * contributor it authorised, supplies.
666
+ *
667
+ * A SEPARATE constructor, as the Broker's is, and for a related reason: the address is a
668
+ * different one. An Exchange advertises CatalogService at its manifest's
669
+ * `catalog_endpoint`, distinct from the ExchangeService endpoint the agent client dials,
670
+ * and the caller is a different party holding a different key — a contributor's, named
671
+ * by `caller_id`, never an agent's. Hanging the catalog verbs on the agent client would
672
+ * carry every agent-only holder into a client that uses none of them, and point one of
673
+ * the two roles at the wrong address.
674
+ *
675
+ * The publisher chose the Exchange, so the origin is configuration and the leg runs on
676
+ * the plain send — the posture of the agent client's home Exchange, not of its
677
+ * offer-derived leg. It takes the same options; `signer` is what a real push needs (an
678
+ * Exchange refuses an unsigned catalog call), and the agent-only ones — the requester,
679
+ * the agent key, the offer-key resolver, the endpoint resolver, the guarded send — are
680
+ * inert here rather than errors, so one option set can build every face.
681
+ */
682
+ export function createCatalogClient(baseURL, options = {}) {
683
+ const r = resolve(options);
684
+ return {
685
+ pushResources: (request) => catalogCall(r, baseURL, "push resources", "PushResources", PushResourcesRequestSchema, PushResourcesResponseSchema, request),
686
+ removeResources: (request) => catalogCall(r, baseURL, "remove resources", "RemoveResources", RemoveResourcesRequestSchema, RemoveResourcesResponseSchema, request),
687
+ refreshCatalog: (request) => catalogCall(r, baseURL, "refresh catalog", "RefreshCatalog", RefreshCatalogRequestSchema, RefreshCatalogResponseSchema, request),
688
+ };
689
+ }
690
+ /**
691
+ * catalogCall is the one shape all three catalog verbs share. The request is CLONED
692
+ * before `ver` is stamped (fill-when-empty; the caller's value is theirs); no
693
+ * idempotency key is stamped, because the messages carry none — a catalog push is an
694
+ * upsert and naturally idempotent, so a key there would be ceremony. `exchange` is the
695
+ * caller's to set, the bare domain of the Exchange the call is meant for; a request
696
+ * that names none, or names something that is not a bare domain, is refused before
697
+ * anything is signed or sent — a refusal to send, the verdict a report with no
698
+ * routable recipient gets, not a malformed message.
699
+ */
700
+ async function catalogCall(r, baseURL, op, method, requestSchema, responseSchema, request) {
701
+ const sent = stampVer(op, request);
702
+ requireRecipient(op, stringField(sent, "exchange"));
703
+ validateRequest(op, sent, requestSchema, r.opts.validation ?? "strict");
704
+ const raw = await call(r, op, baseURL, CATALOG_SERVICE, method, sent, false);
705
+ return parseMessage(op, raw, responseSchema);
706
+ }
707
+ function stampVer(op, message) {
708
+ const sent = clone(op, message);
709
+ if (sent["ver"] === undefined || sent["ver"] === "")
710
+ sent["ver"] = ProtocolVersion;
711
+ return sent;
712
+ }
713
+ // Serves the catalog verbs and the two account verbs, and asks only the SHAPE question.
714
+ //
715
+ // The predicate is isBareDomain, the SHAPE rule, not the routing rule isBareHost. The
716
+ // only question it answers is whether the value is the form the contract admits, which
717
+ // is the protovalidate pattern `exchange` carries and the same rule the Exchange's own
718
+ // audience check applies on arrival. Whether the value can be DIALLED is a separate
719
+ // question with a separate answer: a catalog client is built against an address the
720
+ // publisher configured and never asks it, while the account verbs resolve this domain
721
+ // through its own manifest and ask it there, under the routing predicate. The routing
722
+ // predicate is deliberately wider: an underscore, a trailing root dot and a bracketed
723
+ // IPv6 literal are all usable hosts and none of them is a value this field may hold,
724
+ // so vetting with it would sign and send a request the recipient can only refuse.
725
+ //
726
+ // The refused value is redacted before it is named. A reference carrying userinfo is a
727
+ // verdict rather than a parse failure, so it reaches the message below verbatim; the
728
+ // routing check next door redacts for the same reason, and a tier that echoes is the
729
+ // drift redactUserinfo exists to prevent.
730
+ function requireRecipient(op, exchange) {
731
+ if (exchange === "") {
732
+ throw notSent(op, new Error("request names no recipient; set exchange to the Exchange's bare domain"));
733
+ }
734
+ if (!isBareDomain(exchange)) {
735
+ throw notSent(op, new Error(`exchange ${JSON.stringify(redactUserinfo(exchange))} is not a bare domain`));
736
+ }
737
+ }
738
+ export { ForaCallError } from "./errors.js";
739
+ export { DEFAULT_CALL_TIMEOUT_MS, DEFAULT_MAX_RPC_READ_BYTES, NOT_CANONICAL_WIRE_NAMING, } from "./transport.js";
740
+ export { DEFAULT_CONTENT_TIMEOUT_MS, DEFAULT_MAX_CONTENT_BYTES, } from "./content.js";