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