@axorum/client 0.0.0-stage → 1.1.0

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 (79) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +537 -2
  3. package/dist/agent-uri.d.ts +33 -0
  4. package/dist/agent-uri.d.ts.map +1 -0
  5. package/dist/agent-uri.js +231 -0
  6. package/dist/agent-uri.js.map +1 -0
  7. package/dist/authorize.d.ts +671 -0
  8. package/dist/authorize.d.ts.map +1 -0
  9. package/dist/authorize.js +947 -0
  10. package/dist/authorize.js.map +1 -0
  11. package/dist/capabilities.d.ts +30 -0
  12. package/dist/capabilities.d.ts.map +1 -0
  13. package/dist/capabilities.js +40 -0
  14. package/dist/capabilities.js.map +1 -0
  15. package/dist/chart.d.ts +74 -0
  16. package/dist/chart.d.ts.map +1 -0
  17. package/dist/chart.js +107 -0
  18. package/dist/chart.js.map +1 -0
  19. package/dist/client.d.ts +645 -0
  20. package/dist/client.d.ts.map +1 -0
  21. package/dist/client.js +1303 -0
  22. package/dist/client.js.map +1 -0
  23. package/dist/errors.d.ts +646 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +928 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/failover.d.ts +197 -0
  28. package/dist/failover.d.ts.map +1 -0
  29. package/dist/failover.js +289 -0
  30. package/dist/failover.js.map +1 -0
  31. package/dist/freshness.d.ts +65 -0
  32. package/dist/freshness.d.ts.map +1 -0
  33. package/dist/freshness.js +148 -0
  34. package/dist/freshness.js.map +1 -0
  35. package/dist/generated/agent-plane.d.ts +1865 -0
  36. package/dist/generated/agent-plane.d.ts.map +1 -0
  37. package/dist/generated/agent-plane.js +19 -0
  38. package/dist/generated/agent-plane.js.map +1 -0
  39. package/dist/idempotency.d.ts +160 -0
  40. package/dist/idempotency.d.ts.map +1 -0
  41. package/dist/idempotency.js +208 -0
  42. package/dist/idempotency.js.map +1 -0
  43. package/dist/index.d.ts +90 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +88 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/intent.d.ts +163 -0
  48. package/dist/intent.d.ts.map +1 -0
  49. package/dist/intent.js +82 -0
  50. package/dist/intent.js.map +1 -0
  51. package/dist/json.d.ts +49 -0
  52. package/dist/json.d.ts.map +1 -0
  53. package/dist/json.js +259 -0
  54. package/dist/json.js.map +1 -0
  55. package/dist/key.d.ts +64 -0
  56. package/dist/key.d.ts.map +1 -0
  57. package/dist/key.js +71 -0
  58. package/dist/key.js.map +1 -0
  59. package/dist/payment.d.ts +172 -0
  60. package/dist/payment.d.ts.map +1 -0
  61. package/dist/payment.js +178 -0
  62. package/dist/payment.js.map +1 -0
  63. package/dist/refusal.d.ts +121 -0
  64. package/dist/refusal.d.ts.map +1 -0
  65. package/dist/refusal.js +176 -0
  66. package/dist/refusal.js.map +1 -0
  67. package/dist/retry.d.ts +215 -0
  68. package/dist/retry.d.ts.map +1 -0
  69. package/dist/retry.js +297 -0
  70. package/dist/retry.js.map +1 -0
  71. package/dist/typeid.d.ts +64 -0
  72. package/dist/typeid.d.ts.map +1 -0
  73. package/dist/typeid.js +119 -0
  74. package/dist/typeid.js.map +1 -0
  75. package/dist/types.d.ts +245 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +14 -0
  78. package/dist/types.js.map +1 -0
  79. package/package.json +58 -3
package/dist/client.js ADDED
@@ -0,0 +1,1303 @@
1
+ /**
2
+ * The client, and the one request funnel every call goes through.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import { ConfigError, DeadlineExceededError, DecodeError, EndpointsExhaustedError, MissingAttestationError, NoActivePolicyError, StalePolicyError, TransportError, ConnectError, RATE_LIMIT_EXCEEDED, RateLimitedError, UnexpectedResponseError, IdempotencyWindowError, errorFromBody, isErrorBody, } from "./errors.js";
7
+ import { ensureFresh, mintedAtWire, readIdempotencyWindow } from "./idempotency.js";
8
+ export { mintedAtWire } from "./idempotency.js";
9
+ import { endedAfterRedrive, redrivePause, redrives, runAuthorize, } from "./authorize.js";
10
+ import { Failover, describeRefusal, endpointSet, onEndpoint, outcomeOfStatus, outcomeOfTransport, } from "./failover.js";
11
+ import { pinIntent, settleDraft } from "./intent.js";
12
+ import { parseJson, stringifyJson } from "./json.js";
13
+ import { AUTHORIZE_DEADLINE_MS, AUTHORIZE_RESENT_STATUSES, AUTHORIZE_RETRY_POLICY, SUBMIT_ATTEMPT_FALLBACK_MS, SUBMIT_TIMEOUT_HEADER, advertisedCommitWait, attemptTimeout, liftedSubmitTimeout, checkRetryPolicy, isConnectFailure, parseRetryAfter, submitAttemptTimeout, } from "./retry.js";
14
+ /**
15
+ * How many policies {@link AxorumClient.submitPinned} will pin against before giving up.
16
+ *
17
+ * A bound, not a retry budget: each attempt pins against the policy the service *just
18
+ * told us* is in force, so a second failure means the policy moved again in the
19
+ * microseconds since. Three is generous; a service changing policy faster than that is
20
+ * not one a client can chase, and looping forever would turn a hot policy rotation into
21
+ * a self-inflicted denial of service.
22
+ */
23
+ export const MAX_PINS = 3;
24
+ /** The agent plane's mount point. Not configurable — it is part of the frozen contract. */
25
+ const BASE_PATH = "/api/v1";
26
+ /** The default per-request timeout, in milliseconds. */
27
+ const DEFAULT_TIMEOUT_MS = 30_000;
28
+ /**
29
+ * The statuses the agent plane documents as answers. A client's retry policy never
30
+ * re-sends one: the identical request would be answered identically, or (`421`, `503
31
+ * commit_unavailable`) the answer is the caller's to act on. The Rust SDK registers the
32
+ * same set with `acton-service-client` as accepted statuses, which is what keeps them
33
+ * out of its retry loop. `authorize` has the transport re-send `421` regardless
34
+ * ({@link AUTHORIZE_RESENT_STATUSES}), and sends a `503` again itself, its body read.
35
+ */
36
+ const CONTRACT_STATUSES = new Set([400, 401, 403, 404, 409, 421, 422, 500, 503, 507]);
37
+ /**
38
+ * A typed client for the Axorum agent plane.
39
+ *
40
+ * # Writes carry their credential; so do reads
41
+ *
42
+ * An intent authenticates **in the envelope**: the draft names the `agent://` URI and the
43
+ * PASETO attestation, and the service verifies them before it proposes anything. The
44
+ * reads used to carry nothing at all, and were answered anyway — so any party's
45
+ * obligations were enumerable, and any observed transaction id disclosed another tenant's
46
+ * record.
47
+ *
48
+ * They are credentialed now, with the **same token**: the party-scoped reads present the
49
+ * agent's attestation as `Authorization: Bearer …`, the service takes the reader's
50
+ * identity from that token's verified `agent_uri` claim, and answers only within the
51
+ * party it is bound to. There is no second credential to obtain — an agent that can
52
+ * submit can already read.
53
+ *
54
+ * So a client that reads must hold an attestation:
55
+ *
56
+ * ```ts
57
+ * const axorum = new AxorumClient({ baseUrl: "http://127.0.0.1:8080", attestation: paseto });
58
+ *
59
+ * const outcome = await axorum.submitPinned(draft);
60
+ *
61
+ * // A refusal RESOLVES. Branch on the verdict, never on a status code.
62
+ * if (outcome.posted) {
63
+ * console.log("permitted, and the entries posted");
64
+ * } else {
65
+ * console.log("refused, and the refusal is on the ledger:", outcome.verdict);
66
+ * }
67
+ *
68
+ * // Reads are scoped to the party the attestation is bound to.
69
+ * const owed = await axorum.obligations();
70
+ * ```
71
+ *
72
+ * A client built **without** one still submits and still reads {@link activePolicy} —
73
+ * both carry their own credential or need none — but any party-scoped read is a
74
+ * {@link MissingAttestationError}, raised locally, without a wasted round trip.
75
+ *
76
+ * An {@link AxorumError} means the ledger never got to answer at all.
77
+ */
78
+ export class AxorumClient {
79
+ #baseUrl;
80
+ /** The failover URLs as given, handed on by {@link withAttestation}. */
81
+ #failoverUrls;
82
+ /** Every endpoint's origin, `scheme://host:port`: the base URL's first. */
83
+ #endpoints;
84
+ #onRotation;
85
+ #onRetry;
86
+ /**
87
+ * The endpoint the next call starts at: the last one that gave a definitive answer.
88
+ * Shared with every client {@link withAttestation} derives.
89
+ */
90
+ #preferred = { value: 0 };
91
+ #timeoutMs;
92
+ /** Whether {@link #timeoutMs} is the SDK's default, which a plain submission may lift. */
93
+ #defaultTimeout;
94
+ #fetch;
95
+ /**
96
+ * The caller's PASETO attestation, presented on the party-scoped reads.
97
+ *
98
+ * Held here rather than baked into the funnel's default headers for one reason:
99
+ * **attestations expire**. A token welded on at construction would mean rebuilding the
100
+ * client — and with it whatever the `fetch` it was handed is holding open — to rotate
101
+ * one. Kept beside it, it can be swapped with {@link withAttestation} for the price of
102
+ * a string.
103
+ */
104
+ #attestation;
105
+ #retry;
106
+ /** The budget of one {@link authorize} call, at most {@link AUTHORIZE_DEADLINE_MS}. */
107
+ #authorizeDeadlineMs;
108
+ /** Whether {@link authorize} re-sends an unanswered request. */
109
+ #authorizeRetries;
110
+ /**
111
+ * The policy {@link authorize} last pinned, shared with every client
112
+ * {@link withAttestation} derives, so one rotation costs one `409` for all of them.
113
+ */
114
+ #pin = { value: undefined };
115
+ /**
116
+ * What the service advertised about itself (its contract version and, when clustered,
117
+ * its commit wait), once read, shared with every client {@link withAttestation}
118
+ * derives. A deployment does not change contract under a running client without a
119
+ * restart; a commit wait changed by a restart is picked up by a new client.
120
+ */
121
+ /**
122
+ * The idempotency window the service stated with a wall-clock length, once read,
123
+ * shared with every client {@link withAttestation} derives. Kept only when clocked:
124
+ * the window only ever grows, and an unclocked or unstated one is read again.
125
+ */
126
+ #window = { value: undefined };
127
+ /**
128
+ * The commit wait the service advertised ({@link SUBMIT_TIMEOUT_HEADER}), in
129
+ * milliseconds, once read, shared with every client {@link withAttestation} derives:
130
+ * `null` when it advertises none, as a standalone service does.
131
+ */
132
+ #commitWait = { value: undefined };
133
+ /** Whether the commit-wait fallback has been noted to {@link #debug}, shared as {@link #commitWait} is. */
134
+ #fallbackNoted = { value: false };
135
+ #debug;
136
+ /**
137
+ * Builds a client for the service rooted at `baseUrl`.
138
+ *
139
+ * @throws {ConfigError} If `baseUrl` is not an absolute `http`/`https` URL, a failover
140
+ * URL is not a bare origin of the same scheme or repeats one, the timeout is not a
141
+ * positive number, the retry policy is not whole numbers, or the authorize deadline
142
+ * is not a whole number of milliseconds from 1 to 15 000.
143
+ */
144
+ constructor(options) {
145
+ this.#baseUrl = normalizeBaseUrl(options.baseUrl);
146
+ this.#failoverUrls = Object.freeze([...(options.failoverUrls ?? [])]);
147
+ const set = endpointSet(this.#baseUrl, this.#failoverUrls);
148
+ if ("refusal" in set) {
149
+ throw new ConfigError(`the Axorum client could not be built: ${describeRefusal(set.refusal)}`);
150
+ }
151
+ this.#endpoints = set.endpoints;
152
+ this.#onRotation = options.onRotation;
153
+ this.#onRetry = options.onRetry;
154
+ this.#attestation = options.attestation;
155
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
156
+ this.#defaultTimeout = options.timeoutMs === undefined;
157
+ this.#fetch = options.fetch ?? globalThis.fetch;
158
+ this.#retry = options.retry === undefined ? undefined : Object.freeze({ ...options.retry });
159
+ this.#authorizeDeadlineMs = options.authorizeDeadlineMs ?? AUTHORIZE_DEADLINE_MS;
160
+ this.#authorizeRetries = options.authorizeRetries ?? true;
161
+ this.#debug = options.debug;
162
+ if (!Number.isFinite(this.#timeoutMs) || this.#timeoutMs <= 0) {
163
+ throw new ConfigError(`the timeout must be a positive number of milliseconds, not ${options.timeoutMs}`);
164
+ }
165
+ if (this.#retry !== undefined) {
166
+ try {
167
+ checkRetryPolicy(this.#retry);
168
+ }
169
+ catch (error) {
170
+ throw new ConfigError(`retry: ${error.message}`);
171
+ }
172
+ }
173
+ if (!validAuthorizeDeadline(this.#authorizeDeadlineMs)) {
174
+ throw new ConfigError(`the authorize deadline must be a whole number of milliseconds from 1 to ` +
175
+ `${AUTHORIZE_DEADLINE_MS}, not ${options.authorizeDeadlineMs}`);
176
+ }
177
+ if (this.#endpoints.length === 1) {
178
+ warnOneEndpoint(this.#baseUrl);
179
+ }
180
+ }
181
+ /**
182
+ * The same client, reading as the bearer of a **different** attestation.
183
+ *
184
+ * Attestations expire, so an agent that outlives one of its tokens must be able to
185
+ * re-credential without rebuilding — and this hands the new client the *same* `fetch`,
186
+ * so whatever connections that `fetch` is keeping alive stay alive. Rotating a token
187
+ * costs a string, not a reconnect.
188
+ *
189
+ * It is also the honest way for one process to act for several agents: each gets its
190
+ * own view, and no view can see another's data, because the service scopes every read
191
+ * to the party the presented token names.
192
+ */
193
+ withAttestation(attestation) {
194
+ const client = new AxorumClient({
195
+ baseUrl: this.#baseUrl,
196
+ failoverUrls: this.#failoverUrls,
197
+ ...(this.#onRotation === undefined ? {} : { onRotation: this.#onRotation }),
198
+ ...(this.#onRetry === undefined ? {} : { onRetry: this.#onRetry }),
199
+ attestation,
200
+ timeoutMs: this.#timeoutMs,
201
+ ...(this.#retry === undefined ? {} : { retry: this.#retry }),
202
+ authorizeDeadlineMs: this.#authorizeDeadlineMs,
203
+ authorizeRetries: this.#authorizeRetries,
204
+ ...(this.#debug === undefined ? {} : { debug: this.#debug }),
205
+ fetch: this.#fetch,
206
+ });
207
+ client.#pin = this.#pin;
208
+ client.#window = this.#window;
209
+ client.#defaultTimeout = this.#defaultTimeout;
210
+ client.#commitWait = this.#commitWait;
211
+ client.#fallbackNoted = this.#fallbackNoted;
212
+ client.#preferred = this.#preferred;
213
+ return client;
214
+ }
215
+ /**
216
+ * Asks the ledger whether `agent` may make `payment`, in one call.
217
+ *
218
+ * Proposes `purchase(amount, payee)` with the payee bound as **text**, debiting the
219
+ * default chart's spend account and crediting its payable account in the amount's
220
+ * currency. The policy in force must declare
221
+ * `action purchase(amount: amount, payee: text)`, and an operator must have opened the
222
+ * default chart in that currency. A rail governed by the same policy must be set to
223
+ * `payee_binding = "text"`; one left on `"party"` binds a different type.
224
+ *
225
+ * ```ts
226
+ * import { Agent, AxorumClient, AuthorizeError, payment } from "@axorum/client";
227
+ *
228
+ * const axorum = new AxorumClient({ baseUrl: "https://ledger.acme.com" });
229
+ * const buyer = new Agent("agent://acme.com/agent/buyer_01h455vb4pex5vsknk084sn02q", paseto);
230
+ * const lunch = payment({ amount: { minor: 1_250n, currency: "USD" }, payee: "api.openai.com" });
231
+ *
232
+ * const answer = await axorum.authorize(buyer, lunch);
233
+ * if (answer.allowed) {
234
+ * await pay();
235
+ * } else {
236
+ * console.log(answer.refusal.explanation);
237
+ * }
238
+ * ```
239
+ *
240
+ * # A refusal resolves
241
+ *
242
+ * A payment the policy forbids resolves as `{ allowed: false, refusal }`: it was judged
243
+ * and recorded, and nothing posted. A rejected promise ({@link AuthorizeError}) means the
244
+ * ledger never answered.
245
+ *
246
+ * # Retries and time
247
+ *
248
+ * One call has one budget, {@link AUTHORIZE_DEADLINE_MS} (15 s, or less with
249
+ * {@link AxorumClientOptions.authorizeDeadlineMs}), counted from the call and shared by
250
+ * the idempotency window and policy reads, every submission (the first, and each
251
+ * re-pin), every attempt and every pause. Each submission attempt waits the commit
252
+ * wait the service advertises plus 1 s ({@link submitAttemptTimeout}), so a clustered
253
+ * service's own `503 commit_unavailable` is heard rather than cut off, or 6 s
254
+ * ({@link SUBMIT_ATTEMPT_FALLBACK_MS}) when it advertises none; each read waits 6 s.
255
+ * Every attempt is also clamped to what is left of the budget. When that clamp cuts a
256
+ * submission attempt below the commit wait plus 1 s, the service may still be deciding
257
+ * when the client stops waiting, so a call that ends there is honestly
258
+ * `indeterminate`. The advertisement is read once per client, from
259
+ * `GET /api/openapi.json`.
260
+ * Re-sends follow {@link AUTHORIZE_RETRY_POLICY}: as many as the budget allows, full
261
+ * jitter floored at 100 ms and capped at 1.6 s, honouring `Retry-After` within the
262
+ * budget. The client's own `retry` and `timeoutMs` govern its other calls, not this one;
263
+ * {@link AxorumClientOptions.authorizeRetries} turns re-sending off.
264
+ *
265
+ * | Re-sent until the deadline | Not re-sent |
266
+ * |---|---|
267
+ * | connection failures and timeouts | refusals (they are answers) |
268
+ * | `429`, `502`, `503`, `504` (and `423` with `Retry-After`) | `422`, including a missing chart or an unknown payee |
269
+ * | `421 not_primary`, on the next {@link AxorumClientOptions.failoverUrls} URL | `409 transaction_reused` |
270
+ * | `409` stale pin: re-pinned to `active`, at most 3 times | `400` / `401` / `403` / `404` / `507` |
271
+ *
272
+ * Every send carries the payment's transaction id, so one that did commit replays its
273
+ * answer instead of paying twice. A connection failure, a timeout or a `421` moves the
274
+ * next attempt to the next failover URL at once; a `503` is sent again to the same URL,
275
+ * its body read. Whatever is still unanswered
276
+ * when the budget runs out (a `421` and a `503` included) is `indeterminate`: re-drive
277
+ * the same {@link Payment} to learn what happened. A call that proves it wrote nothing
278
+ * is `not_submitted`. `primaryApiAddr` is a hint, never followed.
279
+ *
280
+ * Once an attempt ends without a definite answer (a timeout, or a re-sent status other
281
+ * than `429`), it may have committed, so a later failure that is not the ledger's own
282
+ * answer about the transaction id (a `403`, a `401`, a `429` or `502` once retries are
283
+ * spent) is `indeterminate` too, with cause `earlier_attempt_in_doubt`. A
284
+ * `409 transaction_reused` and a `422 substrate_rejected` stay what they are.
285
+ *
286
+ * # Time
287
+ *
288
+ * The attestation is read once per submission (the first, and each re-pin), and its
289
+ * `exp` bounds that submission too: every send, and every wait between sends
290
+ * (`Retry-After` included), ends 30 seconds (`ATTESTATION_CLOCK_ALLOWANCE_MS`) before
291
+ * `exp` when that comes before the call's deadline. No send starts past that point, each
292
+ * send's timeout is cut to the time left, and a wait that would reach it ends the
293
+ * re-sends.
294
+ *
295
+ * @throws {AuthorizeError} Carrying the transaction id and a `failure` that says what to
296
+ * do next.
297
+ * @throws The caller's own abort reason, as-is, when `options.signal` fires.
298
+ */
299
+ async authorize(agent, payment, options) {
300
+ const lane = {
301
+ deadline: performance.now() + this.#authorizeDeadlineMs,
302
+ retries: this.#authorizeRetries,
303
+ };
304
+ const withSignal = (signal) => signal === undefined ? undefined : { signal };
305
+ return runAuthorize({
306
+ activePolicy: (signal) => this.#activePolicy(withSignal(signal), lane),
307
+ submit: (envelope, signal, bound) => this.#submitIntent(envelope, withSignal(signal), lane, bound),
308
+ pin: this.#pin,
309
+ keptWindow: () => this.#keptWindow(),
310
+ readWindow: (signal) => this.#readWindow(withSignal(signal), lane),
311
+ }, agent, payment, options?.signal);
312
+ }
313
+ /**
314
+ * Attaches the caller's attestation to a party-scoped read.
315
+ *
316
+ * The single choke point for read credentials: every scoped read goes through it, so
317
+ * none can forget. A client with no attestation fails **here**, locally, naming the
318
+ * omission — rather than sending a bare request and relaying the service's inevitable
319
+ * `401` as though the service had found fault with a credential that was never sent.
320
+ *
321
+ * @throws {MissingAttestationError} If this client holds none.
322
+ */
323
+ #authorize(operation) {
324
+ if (this.#attestation === undefined) {
325
+ throw new MissingAttestationError(operation);
326
+ }
327
+ return { authorization: `Bearer ${this.#attestation}` };
328
+ }
329
+ /**
330
+ * The id of the policy currently in force, or `null` if none is.
331
+ *
332
+ * `null` is **not an error** — the service answers `200` with `{"policy": null}`, and
333
+ * "no policy is in force" is a true and useful fact about a ledger, not a failure to
334
+ * report one.
335
+ *
336
+ * This is the endpoint to re-read after a `409`; {@link submitPinned} does it for you.
337
+ *
338
+ * @throws {TransportError} If the service cannot be reached.
339
+ */
340
+ /**
341
+ * The commit wait the service advertises, read once on `lane` when one is given and
342
+ * then kept for this client and the clients {@link withAttestation} derives.
343
+ * `undefined` when it advertises none, or when this read failed (the next submission
344
+ * reads again).
345
+ */
346
+ async #commitWaitMs(options, lane) {
347
+ return (await this.#readCommitWait(options, lane)).ms;
348
+ }
349
+ /** {@link #commitWaitMs}, and the read's failure when it failed. */
350
+ async #readCommitWait(options, lane) {
351
+ if (this.#commitWait.value === undefined) {
352
+ try {
353
+ await this.#send({
354
+ method: "GET",
355
+ path: "api/openapi.json",
356
+ root: true,
357
+ options,
358
+ ...(lane === undefined ? {} : { lane }),
359
+ headersOnly: true,
360
+ onHeaders: (headers) => this.#keepCommitWait(headers),
361
+ });
362
+ }
363
+ catch (error) {
364
+ // The caller's own cancellation is not a failed read: it ends the call.
365
+ if (options?.signal?.aborted === true) {
366
+ throw error;
367
+ }
368
+ return { ms: undefined, failure: error };
369
+ }
370
+ }
371
+ return { ms: this.#commitWait.value ?? undefined };
372
+ }
373
+ /** Keeps the commit wait `headers` state, unless one is kept already. */
374
+ #keepCommitWait(headers) {
375
+ this.#commitWait.value ??= advertisedCommitWait(headers.get(SUBMIT_TIMEOUT_HEADER)) ?? null;
376
+ }
377
+ /**
378
+ * The bound on a plain submission in place of the SDK's default timeout, when that is
379
+ * too short to outlast the commit wait the service advertises. A timeout the caller set
380
+ * is theirs, and nothing is read for it.
381
+ */
382
+ async #liftedSubmit(options) {
383
+ if (!this.#defaultTimeout) {
384
+ return {};
385
+ }
386
+ const lifted = liftedSubmitTimeout(this.#timeoutMs, await this.#commitWaitMs(options, undefined));
387
+ return lifted === undefined ? {} : { attemptMs: lifted };
388
+ }
389
+ /**
390
+ * How long one submission attempt on `lane` may wait: {@link submitAttemptTimeout} of
391
+ * the advertised commit wait. The advertisement is read once per client; if that read
392
+ * fails, this attempt waits {@link SUBMIT_ATTEMPT_FALLBACK_MS} and the next submission
393
+ * reads again. The lane's deadline still clamps the attempt.
394
+ *
395
+ * The first time an attempt waits the fallback, this says why on the
396
+ * {@link AxorumClientOptions.debug} sink ({@link fallbackNote}), once per client.
397
+ */
398
+ async #submitAttemptMs(options, lane) {
399
+ const { ms, failure } = await this.#readCommitWait(options, lane);
400
+ if (ms === undefined && !this.#fallbackNoted.value) {
401
+ this.#fallbackNoted.value = true;
402
+ this.#debug?.(fallbackNote(failure));
403
+ }
404
+ return submitAttemptTimeout(ms);
405
+ }
406
+ async activePolicy(options) {
407
+ return this.#activePolicy(options, undefined);
408
+ }
409
+ /** {@link activePolicy}, on `lane` when one is given. */
410
+ async #activePolicy(options, lane) {
411
+ const response = await this.#send({
412
+ method: "GET",
413
+ path: "policies/active",
414
+ options,
415
+ ...(lane === undefined ? {} : { lane }),
416
+ });
417
+ return response.policy;
418
+ }
419
+ /**
420
+ * The ledger's currency registry: every declared currency and its minor-unit exponent,
421
+ * in canonical (sorted) code order.
422
+ *
423
+ * Requires an attestation, like every agent-plane read of ledger state, but is not
424
+ * owner-scoped: any bound agent reads the whole registry. From contract 5.0.0 an intent
425
+ * in any currency not listed here is refused with `422` and
426
+ * `cause: "undeclared_currency"`, so this is what to read to learn what the ledger will
427
+ * take. An empty list means nothing is declared.
428
+ *
429
+ * @throws {MissingAttestationError} If this client holds no attestation. Nothing is sent.
430
+ * @throws {AttestationError} If the service refuses the attestation.
431
+ * @throws {TransportError} If the service cannot be reached.
432
+ */
433
+ async currencies(options) {
434
+ return this.#send({
435
+ method: "GET",
436
+ path: "currencies",
437
+ headers: this.#authorize("the currency registry"),
438
+ options,
439
+ });
440
+ }
441
+ /**
442
+ * How long this service answers a retry of a committed transaction id with the
443
+ * original outcome: its idempotency window (contract 5.0.0).
444
+ *
445
+ * Read from the three response headers on `GET /api/openapi.json`
446
+ * ({@link IDEMPOTENCY_WINDOW_HEADERS}); the document itself is the frozen contract and
447
+ * is not parsed. Needs no attestation. `window_seconds` is the window's wall-clock
448
+ * length (24 hours at least, rounded down), or `null` when the service runs no
449
+ * heartbeat and its window has no length in seconds. Read it once and hand it to
450
+ * {@link submitKeyed} or {@link ensureFresh}; it only ever grows.
451
+ *
452
+ * ```ts
453
+ * const window = await client.idempotencyWindow();
454
+ * window.window_seconds; // 86400n
455
+ * ```
456
+ *
457
+ * @throws {IdempotencyWindowError} If the headers state no window: a service older
458
+ * than contract 5.0.0, a writer that could not answer, or a rewritten header.
459
+ * @throws {UnexpectedResponseError} If the spec fetch does not answer `2xx`.
460
+ * @throws {TransportError} If the service cannot be reached.
461
+ */
462
+ async idempotencyWindow(options) {
463
+ return this.#idempotencyWindow(options, undefined);
464
+ }
465
+ /** {@link idempotencyWindow}, on `lane` when one is given. */
466
+ async #idempotencyWindow(options, lane) {
467
+ let window;
468
+ await this.#send({
469
+ method: "GET",
470
+ path: "api/openapi.json",
471
+ root: true,
472
+ options,
473
+ ...(lane === undefined ? {} : { lane }),
474
+ headersOnly: true,
475
+ onHeaders: (headers, status) => {
476
+ window = readIdempotencyWindow(headers, status);
477
+ this.#keepCommitWait(headers);
478
+ },
479
+ });
480
+ if (window === undefined) {
481
+ throw new IdempotencyWindowError(null, "not_advertised", null, null);
482
+ }
483
+ return window;
484
+ }
485
+ /**
486
+ * The wall-clock window this client last read, if it read one.
487
+ *
488
+ * The copy is a safe bound to hold a key to because the service never shrinks its
489
+ * window in ticks: boot raises the standing window to the configured one and never
490
+ * lowers it (`axorum-serve`'s `idempotency::boot_policy`), and the admin route refuses
491
+ * a shorter one with `409 idempotency_window_shrink`. What the copy can miss is served
492
+ * by the service's own check: a submission whose `minted_at_ms` is past its window,
493
+ * less a 5-minute skew margin, is refused with `409 stale_idempotency_key` and nothing
494
+ * is written. And the window can grow after the copy was taken, so a key the copy
495
+ * refuses is held to a fresh read before it is refused ({@link #readWindow}).
496
+ */
497
+ #keptWindow() {
498
+ return this.#window.value;
499
+ }
500
+ /**
501
+ * Reads the service's window, and keeps it when it states a wall-clock length. An
502
+ * unclocked window is returned but not kept, so a heartbeat turned on is seen.
503
+ */
504
+ async #readWindow(options, lane) {
505
+ const window = await this.#idempotencyWindow(options, lane);
506
+ if (window.window_seconds !== null) {
507
+ this.#window.value = window;
508
+ }
509
+ return window;
510
+ }
511
+ /**
512
+ * Submits an envelope under a retry key, refusing the key first if the service's
513
+ * window no longer covers it.
514
+ *
515
+ * Use this for every resubmission you make on your own schedule: a queue that
516
+ * redelivers, a job that restarts. Within the window a resubmission of the identical
517
+ * intent answers the original outcome (`replayed: true`); past it, the answer is
518
+ * undefined and the intent might commit twice, so a stale key is never sent.
519
+ *
520
+ * The envelope goes out with the key's `minted_at_ms`, so a contract 5.0.0 service
521
+ * that runs a heartbeat refuses it too (`409 stale_idempotency_key`, an
522
+ * {@link ApiError}) once it is older than the window less a 5-minute clock-skew
523
+ * margin. A caller-chosen id restated with its true mint time passes that check
524
+ * exactly as a freshly minted one does.
525
+ *
526
+ * # Why re-sending is sound
527
+ *
528
+ * The service looks a transaction id up before it applies anything. A re-send inside
529
+ * the idempotency window returns what the first send committed, and one whose content
530
+ * differs is refused as `409 transaction_reused`, never applied.
531
+ *
532
+ * # Retries and time
533
+ *
534
+ * It runs on {@link authorize}'s transport: one budget of `authorizeDeadlineMs`
535
+ * ({@link AUTHORIZE_DEADLINE_MS} by default), shared by the read of the service's
536
+ * commit wait and every attempt, each bounded by {@link submitAttemptTimeout} of that
537
+ * wait, moving across the `failoverUrls` on a connection failure, a timeout and `421`,
538
+ * and sending a `503` again itself, its body read. `authorizeRetries: false` turns
539
+ * re-sending off here too.
540
+ *
541
+ * | Call | Re-sent? | Moves to the next URL? |
542
+ * |---|---|---|
543
+ * | {@link submitIntent} | no | no |
544
+ * | {@link submitPinned} | only to re-pin after a `409` | no |
545
+ * | `submitKeyed` | yes, until the deadline | yes |
546
+ *
547
+ * ```ts
548
+ * const window = await client.idempotencyWindow();
549
+ * const key = IdempotencyKey.mint();
550
+ * const envelope = pinIntent({ ...draft, transaction: key.transaction }, policy);
551
+ * const outcome = await client.submitKeyed(key, window, envelope);
552
+ * ```
553
+ *
554
+ * @throws {StaleIdempotencyKeyError} If the key is past the window, or the service
555
+ * advertises no wall-clock window. Nothing was sent.
556
+ * @throws {ConfigError} If the envelope's `transaction` is not the key's. Nothing was
557
+ * sent.
558
+ * @throws {TransactionReusedError} If the id committed with different content.
559
+ * @throws {EndpointsExhaustedError | DeadlineExceededError} When no endpoint answered
560
+ * in time. Otherwise as {@link submitIntent}.
561
+ */
562
+ async submitKeyed(key, window, envelope, options) {
563
+ checkKeyed(key, window, envelope, Date.now());
564
+ const lane = {
565
+ deadline: performance.now() + this.#authorizeDeadlineMs,
566
+ retries: this.#authorizeRetries,
567
+ };
568
+ return this.#submitIntent({ ...envelope, minted_at_ms: mintedAtWire(key) }, options, lane);
569
+ }
570
+ /**
571
+ * Submits an intent envelope and returns the ledger's outcome.
572
+ *
573
+ * # A refusal resolves
574
+ *
575
+ * If the policy in force forbids the action, this **resolves** with
576
+ * `{ posted: false, verdict: "ForbiddenRejected", … }` — a `200`. The intent reached
577
+ * the commit point, was judged, and the refusal was *recorded*. That record is the
578
+ * product. Branch on `posted`/`verdict`; a rejected promise means the ledger never got
579
+ * to answer at all.
580
+ *
581
+ * Re-submitting the identical intent under a `transaction` id that already committed
582
+ * replays its stored outcome, marked `replayed: true`, rather than double-posting: the
583
+ * id is the substrate's idempotency key, which is what makes retrying a submission
584
+ * safe within the service's window ({@link idempotencyWindow}). Different content
585
+ * under a committed id is a {@link TransactionReusedError}. A replay also carries
586
+ * `content_verified`: `true` when the resubmitted content matched the stored intent,
587
+ * `false` when the service had no stored content to compare with (an id committed
588
+ * before 1.0.0 whose content hash the old log never recorded, retried inside the
589
+ * window after the upgrade). A `false` replay posted nothing, but it did not check
590
+ * your content either. To have the key's age checked before sending, use
591
+ * {@link submitKeyed}.
592
+ *
593
+ * @throws {ConfigError} If the envelope carries no `minted_at_ms` (a non-negative
594
+ * `bigint` of milliseconds since the Unix epoch). Nothing was sent.
595
+ * @throws {StalePolicyError} If the pin did not hold. Use {@link submitPinned} to
596
+ * handle that automatically.
597
+ * @throws {UnknownAgentError | AttestationError} On an identity or authority failure.
598
+ * @throws {NotPrimaryError} If a follower turned the mutation away. Not followed
599
+ * automatically — see the class docs.
600
+ * @throws {CommitUnavailableError} If a cluster commit could not be confirmed.
601
+ * @throws {TransportError} If the service cannot be reached.
602
+ */
603
+ async submitIntent(envelope, options) {
604
+ return this.#submitIntent(envelope, options, undefined);
605
+ }
606
+ /**
607
+ * {@link submitIntent}, on `lane` and within `bound` when they are given. On the lane,
608
+ * with retries on, a `503` is sent again by this loop, its body read ({@link redrives}),
609
+ * under {@link AUTHORIZE_RETRY_POLICY} until the deadline; `bound` hears each, and so
610
+ * does {@link AxorumClientOptions.onRetry}.
611
+ *
612
+ * The loop exists only because a transport re-send reports a `503`'s status and not its
613
+ * code, so an observer cannot tell `idempotency_unseeded`, which wrote nothing, from
614
+ * `commit_unavailable` (https://github.com/Govcraft/acton-service-client/issues/9, which
615
+ * the TypeScript port mirrors). Once the retried response's code reaches the observer,
616
+ * it collapses into retry-policy configuration.
617
+ */
618
+ async #submitIntent(envelope, options, lane, bound) {
619
+ checkMinted(envelope);
620
+ const deadline = earliest(lane?.deadline, bound?.deadline);
621
+ let pausedAfter;
622
+ for (let redriven = 0;; redriven += 1) {
623
+ let endpoint;
624
+ try {
625
+ return await this.#send({
626
+ method: "POST",
627
+ path: "intents",
628
+ body: envelope,
629
+ options,
630
+ ...(bound === undefined ? {} : { bound }),
631
+ onEnded: (at) => {
632
+ endpoint = at;
633
+ },
634
+ // Each attempt outlasts the service's commit wait, so its `503 commit_unavailable`
635
+ // is heard rather than cut off; on `authorize`'s lane the deadline still clamps it.
636
+ ...(lane === undefined
637
+ ? await this.#liftedSubmit(options)
638
+ : { lane, attemptMs: await this.#submitAttemptMs(options, lane) }),
639
+ });
640
+ }
641
+ catch (thrown) {
642
+ const ended = endedAfterRedrive(pausedAfter, thrown);
643
+ if (ended.heard) {
644
+ throw ended.error;
645
+ }
646
+ const error = ended.error;
647
+ const wait = lane?.retries === true && deadline !== undefined && redrives(error)
648
+ ? redrivePause(redriven, Math.random(), Math.max(0, deadline - performance.now()))
649
+ : undefined;
650
+ if (wait === undefined) {
651
+ if (endpoint !== undefined) {
652
+ bound?.onEnded?.(endpoint);
653
+ }
654
+ throw error;
655
+ }
656
+ if (endpoint !== undefined) {
657
+ bound?.onRedrive?.(endpoint, error);
658
+ this.#observe({ kind: "retry", reason: { kind: "status", status: 503 } }, this.#endpoints.indexOf(endpoint), redriven + 2);
659
+ }
660
+ pausedAfter = error;
661
+ await pause(wait, options?.signal);
662
+ }
663
+ }
664
+ }
665
+ /**
666
+ * Fetches the active policy, pins the draft to it, submits — and if the pin did not
667
+ * hold, re-pins and submits again.
668
+ *
669
+ * This is the loop every correct agent writes, so it ships in the client rather than
670
+ * in each caller. It is the *only* method that recovers automatically, and it is safe
671
+ * to do so for one specific reason: the `transaction` id is minted **once**, before
672
+ * the first attempt, and is the substrate's idempotency key. Every retry carries the
673
+ * same id, so if an earlier attempt in fact landed, the retry replays its stored
674
+ * outcome. The loop cannot double-post.
675
+ *
676
+ * A refusal still resolves (see {@link submitIntent}), and a `421` still rejects:
677
+ * following a redirect to another replica is the caller's decision, not the client's.
678
+ *
679
+ * @throws {NoActivePolicyError} If no policy is in force — there is nothing to pin to.
680
+ * @throws {StalePolicyError} If the policy kept moving out from under {@link MAX_PINS}
681
+ * attempts.
682
+ */
683
+ async submitPinned(draft, options) {
684
+ const policy = await this.activePolicy(options);
685
+ if (policy === null) {
686
+ throw new NoActivePolicyError("no policy is in force, so an intent cannot be pinned to one");
687
+ }
688
+ return this.submitPinnedFrom(draft, policy, options);
689
+ }
690
+ /**
691
+ * {@link submitPinned}, but starting from a policy you already believe is in force —
692
+ * and recovering if you are wrong.
693
+ *
694
+ * This is the shape a long-running agent actually wants. Reading the active policy
695
+ * before *every* submission is a round trip spent re-learning something that changes
696
+ * rarely; an agent can hold the last policy it saw, submit straight against it, and
697
+ * let the `409` tell it when its belief went stale. The recovery is the same loop
698
+ * either way, so caching costs nothing in correctness — the service remains the
699
+ * authority, it is simply consulted only when it disagrees.
700
+ *
701
+ * On a `409` it re-pins against the policy **the rejection itself named**, rather than
702
+ * re-reading the active policy. That is not a shortcut: the rejection is the service's
703
+ * own statement of what is in force as of the moment it refused — strictly fresher
704
+ * than anything a follow-up read could return, and one round trip cheaper. Bounded at
705
+ * {@link MAX_PINS} attempts.
706
+ *
707
+ * @param draft - The intent, minus the pin. Its transaction id and mint time are
708
+ * settled once, here, before the first attempt, and every re-pin carries both.
709
+ * @param policyId - The policy you believe is in force.
710
+ * @throws {StalePolicyError} If the policy kept moving out from under {@link MAX_PINS}
711
+ * attempts — the policy is churning faster than a client can chase it, and looping
712
+ * on would turn a hot rotation into a denial of service.
713
+ */
714
+ async submitPinnedFrom(draft, policyId, options) {
715
+ // Once, before the first attempt — never inside the loop. This single line is what
716
+ // makes every retry below a replay rather than a second posting, and what makes every
717
+ // retry carry the same mint time.
718
+ const settled = settleDraft(draft);
719
+ let policy = policyId;
720
+ for (let attempt = 1;; attempt += 1) {
721
+ try {
722
+ // The refusal path returns from here too, as a resolved value — as it should.
723
+ return await this.submitIntent(pinIntent(settled, policy), options);
724
+ }
725
+ catch (error) {
726
+ // Not a pin problem, so not ours to fix: an attestation failure, a 421, an
727
+ // unreachable service. Hand it straight back. And out of attempts is out of
728
+ // attempts: report the last rejection as the service stated it.
729
+ if (!(error instanceof StalePolicyError) || attempt >= MAX_PINS) {
730
+ throw error;
731
+ }
732
+ // The service named the policy now in force. Pin to it and go again.
733
+ policy = error.active;
734
+ }
735
+ }
736
+ }
737
+ /**
738
+ * The stored record for a committed transaction — **if your party proposed it**.
739
+ *
740
+ * Reading it is the idempotent replay lookup: it tells you what a previously-submitted
741
+ * `transaction` id did, without resubmitting it. A recorded refusal is here like any
742
+ * other record, with `posted: false`.
743
+ *
744
+ * The record is {@link TransactionRecord | contractually opaque} in this version of
745
+ * the wire contract.
746
+ *
747
+ * # A transaction id is not a capability
748
+ *
749
+ * Ids are client-minted, and a counterparty may well know one. Holding an id therefore
750
+ * grants nothing: a transaction another party proposed answers `404`, *identically* to
751
+ * an id that never existed. The two are deliberately indistinguishable — a
752
+ * distinguishable "forbidden" would turn any id into an existence oracle against
753
+ * another tenant's ledger.
754
+ *
755
+ * @throws {MissingAttestationError} If this client holds no attestation.
756
+ * @throws {ApiError} With code `unknown_transaction` (404) if there is no such
757
+ * transaction **or it belongs to another party**.
758
+ */
759
+ async transaction(txnId, options) {
760
+ return this.#send({
761
+ method: "GET",
762
+ path: `transactions/${encodeURIComponent(txnId)}`,
763
+ headers: this.#authorize("a transaction"),
764
+ options,
765
+ });
766
+ }
767
+ /**
768
+ * The **signed receipt** for a transaction your party proposed — an
769
+ * `axorum.evidence.v0` envelope whose base64 `payload` is the
770
+ * `axorum.receipt.v0` facts of the applied record (contract 4.1.0).
771
+ *
772
+ * Re-minted deterministically from the stored record on every call:
773
+ * byte-identical across fetches and to the inline `receipt` the original
774
+ * submission's outcome carried. Verify it **offline** against the key list
775
+ * published at `GET /api/verification-keys.json` — no credential, no server,
776
+ * no SDK required (see `spec/schemas/` and `spec/evidence-signing.md`).
777
+ *
778
+ * @throws {MissingAttestationError} If this client holds no attestation.
779
+ * @throws {ApiError} With `unknown_transaction` (404) if there is no such
780
+ * transaction, it belongs to another party, **or** its record has been
781
+ * archived out of the live tail — deliberately indistinguishable. With
782
+ * `signing_not_configured` / `signing_unavailable` (503) when the
783
+ * deployment does not sign or its signing service is down (the
784
+ * transaction itself is committed and unaffected — retry later).
785
+ */
786
+ async receipt(txnId, options) {
787
+ return this.#send({
788
+ method: "GET",
789
+ path: `transactions/${encodeURIComponent(txnId)}/receipt`,
790
+ headers: this.#authorize("a receipt"),
791
+ options,
792
+ });
793
+ }
794
+ /**
795
+ * The open obligations **your party** owes.
796
+ *
797
+ * Whose duties these are is decided by the attestation, never by an argument. That is
798
+ * the whole of the fix: `?actor=` used to *select* the party — through a deterministic,
799
+ * public hash of a name — so every party's duties were enumerable by anyone who could
800
+ * guess a name, with no credential at all.
801
+ *
802
+ * `actor` is now an assertion the service **checks** rather than obeys. Leave it out and
803
+ * you are answered for the party your token names. Supply it — a party id (`party_…`) or
804
+ * a DSL party name — and the service confirms it resolves to that same party, answering
805
+ * `403` if it does not. It is there so a caller can be explicit about who it believes it
806
+ * is, and be told when it is wrong; it can no longer be used to ask about somebody else.
807
+ *
808
+ * A duty-free party is not an error — it answers with an empty list.
809
+ *
810
+ * @throws {MissingAttestationError} If this client holds no attestation.
811
+ * @throws {ApiError} With `403` if `actor` names a party other than the one your
812
+ * attestation is bound to.
813
+ */
814
+ async obligations(actor, options) {
815
+ return this.#send({
816
+ method: "GET",
817
+ path: "obligations",
818
+ ...(actor === undefined ? {} : { query: { actor } }),
819
+ headers: this.#authorize("obligations"),
820
+ options,
821
+ });
822
+ }
823
+ /**
824
+ * An account's net balance, in minor units.
825
+ *
826
+ * The balance is a **`bigint`**: the ledger's minor units are 128-bit integers, and
827
+ * decoding one as a JavaScript `number` would truncate it silently. See `src/json.ts`.
828
+ *
829
+ * # Scope
830
+ *
831
+ * Requires an attestation **and is scoped to the account's owning party**: the service
832
+ * answers only when the attested party is the recorded owner of the account. An account
833
+ * owned by another party — or one opened with no owner at all — answers exactly like an
834
+ * account that does not exist (`404 unknown_account`), fail-closed and deliberately
835
+ * indistinguishable: an account id is never an existence oracle across a tenant boundary.
836
+ *
837
+ * @throws {MissingAttestationError} If this client holds no attestation.
838
+ * @throws {ApiError} With code `unknown_account` (404) if there is no such account *or*
839
+ * the attested party does not own it (the caller cannot tell which).
840
+ */
841
+ async balance(accountId, options) {
842
+ return this.#send({
843
+ method: "GET",
844
+ path: `accounts/${encodeURIComponent(accountId)}/balance`,
845
+ headers: this.#authorize("a balance"),
846
+ options,
847
+ });
848
+ }
849
+ /**
850
+ * A usage balance's three running totals — **if your party owns it**.
851
+ *
852
+ * The report carries `consumed`, `metered` and `billed`, the two headrooms derived from
853
+ * them (`unmetered`, `unbilled`), whether the chain has `reconciled`, and `text` — the
854
+ * balance narrated in compliance English with exact figures. Prefer `text` when you have
855
+ * a human to show: it is the service's own sentence, not a rendering you assemble.
856
+ *
857
+ * # Every counter is a `bigint`
858
+ *
859
+ * More so here than anywhere else on the plane. `consumed` is the top of the chain and
860
+ * takes **no ceiling** — a busy meter can lawfully outgrow `u64` — so decoding a counter
861
+ * as a JavaScript `number` would eventually truncate a real figure silently. This client
862
+ * parses them as `bigint`s; see `src/json.ts`.
863
+ *
864
+ * # Scope
865
+ *
866
+ * Owner-scoped, and fail-closed. A balance owned by another party, one opened with no
867
+ * owner at all, and one that was never opened all answer the **same** `404 unknown_usage`
868
+ * — deliberately indistinguishable, so that a usage id is never an existence oracle
869
+ * across a tenant boundary. The refusal names no owner and does not disclose whether the
870
+ * balance exists; surface it as it stands rather than inferring more from it than it says.
871
+ *
872
+ * @throws {MissingAttestationError} If this client holds no attestation.
873
+ * @throws {ApiError} With code `unknown_usage` (404) if there is no such usage balance
874
+ * *or* the attested party does not own it (the caller cannot tell which), or
875
+ * `invalid_usage_id` (400) if that is not a usage-balance id.
876
+ */
877
+ async usage(usageId, options) {
878
+ return this.#send({
879
+ method: "GET",
880
+ path: `usages/${encodeURIComponent(usageId)}`,
881
+ headers: this.#authorize("a usage balance"),
882
+ options,
883
+ });
884
+ }
885
+ /**
886
+ * The one funnel every call goes through: build, send, then decode.
887
+ *
888
+ * The whole of this client's wire handling lives here — which is the point. A success
889
+ * body decodes into `T`; a failure body decodes through {@link errorFromBody} into the
890
+ * class that says what to do about it, with its **extras intact**. A generic client
891
+ * would flatten those extras away, and without `pinned`/`active` no client can re-pin,
892
+ * and without `primary_api_addr` none can tell which replica holds the primary.
893
+ *
894
+ * It does **not** decide who the caller is. The `Authorization` header arrives already
895
+ * built, from {@link #authorize}, and only the party-scoped reads ask for it: a submit
896
+ * carries its attestation *inside* the envelope, and `policies/active` discloses no
897
+ * party's data and needs none. A funnel that credentialed everything by default is the
898
+ * mechanism this replaced.
899
+ *
900
+ * The body is read as **text** before being parsed, so that a body which fails to
901
+ * parse can be handed to a human verbatim. An unreadable failure is exactly the moment
902
+ * you need to see the actual bytes.
903
+ *
904
+ * On `authorize`'s lane, or for a read under a {@link AxorumClientOptions.retry | retry}
905
+ * policy, a send that failed in transit is re-sent with the same bytes and the same
906
+ * `x-request-id`, to the same endpoint or the next, exactly as `acton-service-client`
907
+ * 0.2.1 does for the Rust SDK; `retry.ts` and `failover.ts` hold the rules. When the
908
+ * budget stops the loop, the last answer or failure is what the call ends on, or an
909
+ * {@link EndpointsExhaustedError} carrying every attempt once the call has moved
910
+ * endpoints; a request the deadline left no time to send is a
911
+ * {@link DeadlineExceededError}.
912
+ */
913
+ async #send(request) {
914
+ const url = this.#url(request.path, request.query, request.root === true);
915
+ const headers = {
916
+ accept: "application/json",
917
+ // Always present, generated when not supplied: a request you cannot correlate with
918
+ // a log line is a request you cannot debug. The same id rides every re-send.
919
+ "x-request-id": request.options?.requestId ?? crypto.randomUUID(),
920
+ ...request.headers,
921
+ };
922
+ if (request.body !== undefined) {
923
+ headers["content-type"] = "application/json";
924
+ }
925
+ // `stringifyJson`, not `JSON.stringify`: the envelope carries 128-bit money as
926
+ // `bigint`, which `JSON.stringify` refuses outright.
927
+ const body = request.body === undefined ? undefined : stringifyJson(request.body);
928
+ const caller = request.options?.signal;
929
+ const { bound, lane } = request;
930
+ const policy = sendPolicy(request.method, lane, this.#retry);
931
+ // A submission on the lane re-sends `421` too; its reads, being reads, need not.
932
+ const retryOn = lane !== undefined && request.method === "POST" ? AUTHORIZE_RESENT_STATUSES : [];
933
+ const attemptMs = lane === undefined ? request.attemptMs : (request.attemptMs ?? SUBMIT_ATTEMPT_FALLBACK_MS);
934
+ const started = performance.now();
935
+ // Whichever comes first: the call's, the submission's own, the policy's.
936
+ const deadline = earliest(earliest(lane?.deadline, bound?.deadline), policy?.deadlineMs === undefined ? undefined : started + policy.deadlineMs);
937
+ const remaining = () => deadline === undefined ? undefined : Math.max(0, deadline - performance.now());
938
+ const failover = new Failover(this.#endpoints.length, this.#preferred.value);
939
+ // The previous attempt's failure, ended on if the budget runs out before the next,
940
+ // and whether its status was a documented answer.
941
+ let last;
942
+ // `listed`: the loop gave up on `final` while it still asked for another try, so the
943
+ // trace ends with it. On `authorize`'s lane such an answer is returned with every
944
+ // attempt before it, as the Rust SDK reads `AttemptTrace` off the response, so
945
+ // `authorize` can tell a call that reached nothing able to apply it from one that
946
+ // may have committed. The caller's lane keeps the plain error.
947
+ const finish = (stop, final, listed) => {
948
+ const elapsedMs = performance.now() - started;
949
+ if (final === undefined) {
950
+ throw new DeadlineExceededError(failover.attempts, elapsedMs);
951
+ }
952
+ if (stop === "exhausted" && !final.accepted) {
953
+ throw new EndpointsExhaustedError(failover.trace(this.#endpoints), final.error, elapsedMs);
954
+ }
955
+ if (listed && final.accepted && lane !== undefined) {
956
+ throw new EndpointsExhaustedError(failover.trace(this.#endpoints), final.error, elapsedMs);
957
+ }
958
+ throw final.error;
959
+ };
960
+ // The endpoint of the attempt sent last.
961
+ let sentTo;
962
+ const ended = () => {
963
+ const at = sentTo === undefined ? undefined : this.#endpoints[sentTo];
964
+ if (at !== undefined) {
965
+ request.onEnded?.(at);
966
+ }
967
+ };
968
+ for (;;) {
969
+ const step = failover.begin(remaining());
970
+ if ("stop" in step) {
971
+ ended();
972
+ return finish(step.stop, last, false);
973
+ }
974
+ // A re-send is heard as it goes out, so a pause that reaches the deadline ends the
975
+ // loop unheard, as acton's does.
976
+ if (step.resend !== undefined) {
977
+ const { resend } = step;
978
+ const at = this.#endpoints[resend.kind === "rotation" ? resend.from : step.endpoint];
979
+ if (at !== undefined) {
980
+ bound?.onResend({ endpoint: at, outcome: resend.reason, rotated: resend.kind === "rotation" });
981
+ }
982
+ this.#observe(resend, step.endpoint, step.number);
983
+ }
984
+ sentTo = step.endpoint;
985
+ const timeoutMs = attemptTimeout(undefined, attemptMs, this.#timeoutMs, remaining()) ?? this.#timeoutMs;
986
+ const target = this.#endpoints.length === 1 ? url : onEndpoint(url, this.#endpoints[step.endpoint] ?? url);
987
+ const exchanged = await this.#exchange(target, request.method, headers, body, caller, timeoutMs);
988
+ let outcome;
989
+ let failed;
990
+ if ("failure" in exchanged) {
991
+ outcome = outcomeOfTransport(exchanged.connect, exchanged.timeout);
992
+ failed = { error: exchanged.failure, accepted: false };
993
+ }
994
+ else {
995
+ const { status, text } = exchanged;
996
+ if (status >= 200 && status < 300) {
997
+ request.onHeaders?.(exchanged.headers, status);
998
+ this.#preferred.value = step.endpoint;
999
+ if (request.headersOnly === true) {
1000
+ return undefined;
1001
+ }
1002
+ try {
1003
+ return parseJson(text);
1004
+ }
1005
+ catch (error) {
1006
+ throw new DecodeError(status, describe(error), text);
1007
+ }
1008
+ }
1009
+ const accepted = CONTRACT_STATUSES.has(status);
1010
+ outcome = outcomeOfStatus(status, exchanged.retryAfterMs, accepted, retryOn);
1011
+ if (outcome.kind !== "rotate") {
1012
+ this.#preferred.value = step.endpoint;
1013
+ }
1014
+ failed = { error: failure(status, text, exchanged.retryAfterMs ?? null), accepted };
1015
+ }
1016
+ const next = failover.after(outcome, policy, Math.random, remaining());
1017
+ if ("stop" in next) {
1018
+ ended();
1019
+ return finish(next.stop, failed, outcome.kind !== "final");
1020
+ }
1021
+ last = failed;
1022
+ if (next.kind === "after") {
1023
+ await pause(next.pauseMs, caller);
1024
+ }
1025
+ }
1026
+ }
1027
+ /**
1028
+ * Tells {@link AxorumClientOptions.onRotation} or {@link AxorumClientOptions.onRetry}
1029
+ * about a re-send as it goes out, if either listens. What they throw is ignored: an
1030
+ * observer's failure is not the call's.
1031
+ */
1032
+ #observe(resend, endpoint, attempt) {
1033
+ try {
1034
+ if (resend.kind === "rotation") {
1035
+ const left = this.#endpoints[resend.from];
1036
+ if (left !== undefined) {
1037
+ this.#onRotation?.(left, resend.reason);
1038
+ }
1039
+ }
1040
+ else {
1041
+ const at = this.#endpoints[endpoint];
1042
+ if (at !== undefined) {
1043
+ this.#onRetry?.(at, resend.reason, attempt);
1044
+ }
1045
+ }
1046
+ }
1047
+ catch {
1048
+ // Documented as ignored.
1049
+ }
1050
+ }
1051
+ /**
1052
+ * One send, bounded by `timeoutMs`: the status and body text, or the transport failure
1053
+ * and how it ended (a connection that never opened, or the timeout).
1054
+ */
1055
+ async #exchange(url, method, headers, body, caller, timeoutMs) {
1056
+ // Whole milliseconds: a timer takes no fraction, and rounding up never cuts an
1057
+ // attempt short of its budget.
1058
+ const timeout = AbortSignal.timeout(Math.ceil(timeoutMs));
1059
+ const signal = caller === undefined ? timeout : AbortSignal.any([caller, timeout]);
1060
+ let response;
1061
+ try {
1062
+ response = await this.#fetch(url, {
1063
+ method,
1064
+ headers,
1065
+ ...(body === undefined ? {} : { body }),
1066
+ // A redirect could lead out of the endpoint set; none is followed with more
1067
+ // than one endpoint, and the 3xx ends the call.
1068
+ ...(this.#endpoints.length > 1 ? { redirect: "manual" } : {}),
1069
+ signal,
1070
+ });
1071
+ }
1072
+ catch (error) {
1073
+ // A cancellation the caller asked for is not a failure of the ledger, and is not
1074
+ // dressed up as one — it is rethrown exactly as it was raised.
1075
+ if (caller?.aborted === true) {
1076
+ throw error;
1077
+ }
1078
+ if (!timeout.aborted && isConnectFailure(error)) {
1079
+ return {
1080
+ failure: new ConnectError(`no connection to the Axorum ledger service could be opened, so nothing was sent: ${describe(error)}`, error),
1081
+ connect: true,
1082
+ timeout: false,
1083
+ };
1084
+ }
1085
+ return {
1086
+ failure: new TransportError(`the Axorum ledger service could not be reached: ${describe(error)}`, error),
1087
+ connect: false,
1088
+ timeout: timeout.aborted,
1089
+ };
1090
+ }
1091
+ const status = response.status;
1092
+ try {
1093
+ const text = await response.text();
1094
+ return {
1095
+ status,
1096
+ text,
1097
+ headers: response.headers,
1098
+ retryAfterMs: parseRetryAfter(response.headers.get("retry-after")),
1099
+ };
1100
+ }
1101
+ catch (error) {
1102
+ if (caller?.aborted === true) {
1103
+ throw error;
1104
+ }
1105
+ return {
1106
+ failure: new TransportError(`the Axorum ledger service's ${status} response body could not be read: ${describe(error)}`, error),
1107
+ connect: false,
1108
+ timeout: false,
1109
+ };
1110
+ }
1111
+ }
1112
+ /**
1113
+ * Builds the absolute URL for a route: `{baseUrl}/api/v1/{path}[?query]`, or
1114
+ * `{baseUrl}/{path}` for a route at the application root.
1115
+ */
1116
+ #url(path, query, root = false) {
1117
+ const url = new URL(root ? `${this.#baseUrl}/${path}` : `${this.#baseUrl}${BASE_PATH}/${path}`);
1118
+ if (query !== undefined) {
1119
+ for (const [key, value] of Object.entries(query)) {
1120
+ url.searchParams.set(key, value);
1121
+ }
1122
+ }
1123
+ return url.toString();
1124
+ }
1125
+ }
1126
+ /**
1127
+ * The error a non-2xx answer raises: the documented class its agent-plane error body
1128
+ * names, {@link RateLimitedError} for the service's own limiter's `429`, or
1129
+ * {@link UnexpectedResponseError} carrying the bytes verbatim when the body is neither
1130
+ * (a proxy, a gateway, a load balancer).
1131
+ */
1132
+ function failure(status, text, retryAfterMs) {
1133
+ let body;
1134
+ try {
1135
+ body = parseJson(text);
1136
+ }
1137
+ catch {
1138
+ return new UnexpectedResponseError(status, text);
1139
+ }
1140
+ const limited = limiterMessage(status, body);
1141
+ if (limited !== undefined) {
1142
+ return new RateLimitedError(limited);
1143
+ }
1144
+ if (!isErrorBody(body)) {
1145
+ return new UnexpectedResponseError(status, text);
1146
+ }
1147
+ return errorFromBody(status, body, retryAfterMs);
1148
+ }
1149
+ /**
1150
+ * The limiter's `error` text when `body` is the `429` the service's own rate limiter
1151
+ * answers with (`code: "RATE_LIMIT_EXCEEDED"`), or `undefined`. Pure.
1152
+ */
1153
+ function limiterMessage(status, body) {
1154
+ if (status !== 429 || typeof body !== "object" || body === null) {
1155
+ return undefined;
1156
+ }
1157
+ const { code, error } = body;
1158
+ if (code !== RATE_LIMIT_EXCEEDED) {
1159
+ return undefined;
1160
+ }
1161
+ return typeof error === "string" ? error : "";
1162
+ }
1163
+ /**
1164
+ * The local checks {@link AxorumClient.submitKeyed} makes before anything is sent: the
1165
+ * envelope carries the key's id, and the window still covers the key.
1166
+ *
1167
+ * @internal Exported for tests.
1168
+ * @throws {ConfigError | StaleIdempotencyKeyError}
1169
+ */
1170
+ export function checkKeyed(key, window, envelope, nowMs) {
1171
+ if (envelope.transaction !== key.transaction) {
1172
+ throw new ConfigError(`the envelope's transaction '${envelope.transaction}' is not the key's '${key.transaction}': ` +
1173
+ `a key vouches only for its own id. Build the envelope with transaction: key.transaction.`);
1174
+ }
1175
+ ensureFresh(key, window, nowMs);
1176
+ }
1177
+ /**
1178
+ * Refuses an envelope that carries no mint time, before it is sent: `minted_at_ms` must
1179
+ * be a non-negative `bigint` of milliseconds since the Unix epoch, as the wire type says. The type
1180
+ * requires it; this holds a caller the type cannot reach (plain JavaScript, a cast).
1181
+ * Pure.
1182
+ *
1183
+ * @internal Exported for tests.
1184
+ * @throws {ConfigError}
1185
+ */
1186
+ export function checkMinted(envelope) {
1187
+ const minted = envelope.minted_at_ms;
1188
+ if (typeof minted !== "bigint" || minted < 0n) {
1189
+ throw new ConfigError(`the envelope for transaction '${envelope.transaction}' carries no minted_at_ms: ` +
1190
+ `build it with pinIntent, or set the time its transaction id was first issued`);
1191
+ }
1192
+ }
1193
+ /**
1194
+ * Validates the base URL and strips any trailing slash, so that joining `/api/v1` to it
1195
+ * never produces a double slash.
1196
+ *
1197
+ * @throws {ConfigError} If it is not an absolute `http`/`https` URL.
1198
+ */
1199
+ function normalizeBaseUrl(baseUrl) {
1200
+ let parsed;
1201
+ try {
1202
+ parsed = new URL(baseUrl);
1203
+ }
1204
+ catch {
1205
+ throw new ConfigError(`the Axorum client could not be built: '${baseUrl}' is not a URL`);
1206
+ }
1207
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1208
+ throw new ConfigError(`the Axorum client could not be built: '${baseUrl}' is not an http or https URL`);
1209
+ }
1210
+ return baseUrl.replace(/\/+$/, "");
1211
+ }
1212
+ /** The best available prose for a thrown value, without assuming it is an `Error`. */
1213
+ function describe(error) {
1214
+ if (error instanceof Error) {
1215
+ return error.message === "" ? error.name : error.message;
1216
+ }
1217
+ return String(error);
1218
+ }
1219
+ /**
1220
+ * Why a submission attempt waits {@link SUBMIT_ATTEMPT_FALLBACK_MS}: the service
1221
+ * advertises no commit wait (`failure` absent), or reading it failed with `failure`.
1222
+ * Pure. Internal: exported for this package's tests, never from its index.
1223
+ */
1224
+ export function fallbackNote(failure) {
1225
+ return failure === undefined
1226
+ ? `the service advertises no ${SUBMIT_TIMEOUT_HEADER}; each submission attempt waits the ` +
1227
+ `${SUBMIT_ATTEMPT_FALLBACK_MS} ms fallback`
1228
+ : `reading the service's commit wait failed (${describe(failure)}); this submission attempt ` +
1229
+ `waits the ${SUBMIT_ATTEMPT_FALLBACK_MS} ms fallback`;
1230
+ }
1231
+ /** The earlier of two deadlines, either of which may be absent. Pure. */
1232
+ export function earliest(a, b) {
1233
+ return a === undefined ? b : b === undefined ? a : Math.min(a, b);
1234
+ }
1235
+ /**
1236
+ * The retry policy a request is sent under, or `undefined` to send it once. Pure.
1237
+ *
1238
+ * `authorize`'s lane re-sends every request of the call, its `POST /intents` included:
1239
+ * the service answers a resubmitted transaction id with the stored outcome, never
1240
+ * applying it twice (`POST /intents`, crates/axorum-serve/src/routes/agent.rs), so a
1241
+ * re-send to the same endpoint or the next is safe. Off the lane only reads are
1242
+ * re-sent: a caller's own envelope is sent once, to one endpoint.
1243
+ */
1244
+ export function sendPolicy(method, lane, retry) {
1245
+ if (lane !== undefined) {
1246
+ return lane.retries ? AUTHORIZE_RETRY_POLICY : undefined;
1247
+ }
1248
+ return method === "GET" ? retry : undefined;
1249
+ }
1250
+ /** What a client built with one endpoint logs, once per process. */
1251
+ export const ONE_ENDPOINT_WARNING = "one endpoint configured: an LB routing to the primary is assumed";
1252
+ /** Whether {@link ONE_ENDPOINT_WARNING} has been emitted in this process. */
1253
+ const oneEndpoint = { warned: false };
1254
+ /** Emits {@link ONE_ENDPOINT_WARNING} for the first client built with one endpoint. */
1255
+ function warnOneEndpoint(baseUrl) {
1256
+ warnOnce(oneEndpoint, emitWarning, baseUrl);
1257
+ }
1258
+ /**
1259
+ * Emits {@link ONE_ENDPOINT_WARNING}, naming `baseUrl`, unless `state` says it already
1260
+ * was. The testable seam of {@link warnOneEndpoint}.
1261
+ */
1262
+ export function warnOnce(state, emit, baseUrl) {
1263
+ if (state.warned) {
1264
+ return;
1265
+ }
1266
+ state.warned = true;
1267
+ emit(`${ONE_ENDPOINT_WARNING} (${baseUrl}); name every replica in failoverUrls otherwise`);
1268
+ }
1269
+ /**
1270
+ * A process warning where the runtime has them (Node's `process.emitWarning`, which
1271
+ * `--no-warnings` silences and `process.on("warning")` observes), `console.warn`
1272
+ * elsewhere.
1273
+ */
1274
+ function emitWarning(message) {
1275
+ const process = globalThis
1276
+ .process;
1277
+ if (typeof process?.emitWarning === "function") {
1278
+ process.emitWarning(message, { type: "AxorumWarning", code: "AXORUM_ONE_ENDPOINT" });
1279
+ }
1280
+ else {
1281
+ console.warn(message);
1282
+ }
1283
+ }
1284
+ /** Waits `ms` before a re-send, or rejects with the caller's reason if it cancels first. */
1285
+ async function pause(ms, caller) {
1286
+ caller?.throwIfAborted();
1287
+ await new Promise((resolve, reject) => {
1288
+ const onAbort = () => {
1289
+ clearTimeout(timer);
1290
+ reject(caller?.reason);
1291
+ };
1292
+ const timer = setTimeout(() => {
1293
+ caller?.removeEventListener("abort", onAbort);
1294
+ resolve();
1295
+ }, ms);
1296
+ caller?.addEventListener("abort", onAbort, { once: true });
1297
+ });
1298
+ }
1299
+ /** Whether `ms` is a budget `authorizeDeadlineMs` accepts: whole, positive, at most 15 000. Pure. */
1300
+ export function validAuthorizeDeadline(ms) {
1301
+ return Number.isSafeInteger(ms) && ms > 0 && ms <= AUTHORIZE_DEADLINE_MS;
1302
+ }
1303
+ //# sourceMappingURL=client.js.map