@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
@@ -0,0 +1,947 @@
1
+ /**
2
+ * The one-call `authorize`: who is paying, whether they may, and the pin loop that gets
3
+ * the ledger's answer.
4
+ *
5
+ * Everything here but {@link runAuthorize} is a pure function, testable without a
6
+ * socket. Re-sending a request that failed in transit is the client's job, under
7
+ * `authorize`'s own policy and one deadline for the call; see `retry.ts`.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ import { ApiError, AttestationError, AxorumError, CommitUnavailableError, ConnectError, DeadlineExceededError, DecodeError, IdempotencyWindowError, EndpointsExhaustedError, InvalidAgentError, NoActivePolicyError, NotPrimaryError, StaleIdempotencyKeyError, StalePolicyError, RateLimitedError, TransactionReusedError, TransportError, UnexpectedResponseError, UnknownAgentError, } from "./errors.js";
12
+ import { agentUriProblem } from "./agent-uri.js";
13
+ import { describeAttempt, rotationLabel } from "./failover.js";
14
+ import { pinIntent } from "./intent.js";
15
+ import { DEFAULT_CHART_V1 } from "./chart.js";
16
+ import { ATTESTATION_MIN_REMAINING_MS, attestationExpiryMs, attestationFreshness, formatUtcMs, submissionDeadline, } from "./freshness.js";
17
+ import { ensureFresh } from "./idempotency.js";
18
+ import { AUTHORIZE_RETRY_POLICY, pause, retryLabel } from "./retry.js";
19
+ import { planPayment } from "./payment.js";
20
+ import { isKnownSubstrateCause } from "./refusal.js";
21
+ /**
22
+ * The paying agent: its `agent://` URI and where its attestation comes from.
23
+ *
24
+ * The URI must be in **canonical form**: the complete grammar the service parses, with a
25
+ * lowercase scheme and host, a port without leading zeros, an IPv4 address with no leading
26
+ * zero in an octet, an IPv6 address as RFC 5952 writes it, and no query or fragment. A keyed payment derives its id from these exact bytes, so
27
+ * one spelling per agent is what makes this SDK and the Rust one derive the same id. The
28
+ * service answers `unknown_agent` for a well-formed URI no party is bound to.
29
+ *
30
+ * ```ts
31
+ * import { Agent } from "@axorum/client";
32
+ *
33
+ * // A static token:
34
+ * const buyer = new Agent("agent://acme.com/agent/buyer_01h455vb4pex5vsknk084sn02q", paseto);
35
+ *
36
+ * // Or a source asked on every submission, so an expiring token is refreshed:
37
+ * const refreshing = new Agent(
38
+ * "agent://acme.com/agent/buyer_01h455vb4pex5vsknk084sn02q",
39
+ * () => issuer.mint(),
40
+ * );
41
+ * ```
42
+ */
43
+ export class Agent {
44
+ /** The `agent://` URI, in canonical form: exactly the string the constructor accepted. */
45
+ uri;
46
+ #attestation;
47
+ /**
48
+ * @throws {InvalidAgentError} If `uri` is not an `agent://` URI in canonical form (its
49
+ * `canonical` then names the form to pass, when there is one), or a static
50
+ * attestation is not a PASETO v4.public token.
51
+ */
52
+ constructor(uri, attestation) {
53
+ const refused = agentUriProblem(uri);
54
+ if (refused !== undefined) {
55
+ throw new InvalidAgentError(refused.canonical === undefined
56
+ ? `'${uri}' is not an agent:// URI (${refused.reason}); pass the URI exactly as your ` +
57
+ `operator bound it, for example agent://acme.com/agent/buyer_01h455vb4pex5vsknk084sn02q`
58
+ : `'${uri}' is not a canonical agent:// URI (${refused.reason}); pass '${refused.canonical}'`, refused.canonical);
59
+ }
60
+ if (typeof attestation === "string") {
61
+ const problem = attestationProblem(attestation) ?? claimsProblem(attestation);
62
+ if (problem !== undefined) {
63
+ throw new InvalidAgentError(problem);
64
+ }
65
+ }
66
+ this.uri = uri;
67
+ this.#attestation = attestation;
68
+ }
69
+ /**
70
+ * The attestation for one submission: the token, or a fresh one from the source,
71
+ * with at least {@link ATTESTATION_MIN_REMAINING_MS} left before its `exp`.
72
+ *
73
+ * The service allows no leeway on `exp`, and a submission may be re-sent for up to
74
+ * its budget, so a token with less left could expire between two sends of the same
75
+ * payment. A source whose token is that close is asked once more; nothing is sent
76
+ * unless a token is fresh.
77
+ *
78
+ * @param now - The current time in epoch milliseconds; the system clock by default.
79
+ * @throws {AuthorizeError} Of kind `attestation_expiring` if no token had enough time
80
+ * left, or `attestation_unavailable` if the source failed or returned a token whose
81
+ * claims cannot be read.
82
+ */
83
+ async attestation(transaction, now = Date.now) {
84
+ const token = await this.#ask(transaction);
85
+ const freshness = judgeToken(transaction, token, now());
86
+ if (freshness.state === "fresh") {
87
+ return token;
88
+ }
89
+ if (typeof this.#attestation === "string") {
90
+ throw new AuthorizeError(transaction, { kind: "attestation_expiring", expiresAt: freshness.expiresAt });
91
+ }
92
+ const again = await this.#ask(transaction);
93
+ const refreshed = judgeToken(transaction, again, now());
94
+ if (refreshed.state === "fresh") {
95
+ return again;
96
+ }
97
+ throw new AuthorizeError(transaction, { kind: "attestation_expiring", expiresAt: refreshed.expiresAt });
98
+ }
99
+ /** One token from the static value or the source, checked for shape. */
100
+ async #ask(transaction) {
101
+ if (typeof this.#attestation === "string") {
102
+ return this.#attestation;
103
+ }
104
+ let token;
105
+ try {
106
+ token = await this.#attestation();
107
+ }
108
+ catch (cause) {
109
+ throw new AuthorizeError(transaction, { kind: "attestation_unavailable" }, cause);
110
+ }
111
+ const problem = attestationProblem(token);
112
+ if (problem !== undefined) {
113
+ throw new AuthorizeError(transaction, { kind: "attestation_unavailable" }, new InvalidAgentError(problem));
114
+ }
115
+ return token;
116
+ }
117
+ }
118
+ /**
119
+ * The token's freshness at `nowMs`, with an unreadable token already thrown as
120
+ * `attestation_unavailable`.
121
+ */
122
+ function judgeToken(transaction, token, nowMs) {
123
+ const freshness = attestationFreshness(token, nowMs, ATTESTATION_MIN_REMAINING_MS);
124
+ if (freshness.state === "unreadable") {
125
+ throw new AuthorizeError(transaction, { kind: "attestation_unavailable" }, new InvalidAgentError(freshness.problem));
126
+ }
127
+ return freshness;
128
+ }
129
+ /** Why a token's claims cannot be read, or `undefined`; independent of the time. Pure. */
130
+ function claimsProblem(token) {
131
+ const freshness = attestationFreshness(token, 0, 0);
132
+ return freshness.state === "unreadable" ? freshness.problem : undefined;
133
+ }
134
+ /** Why `token` is not a usable PASETO v4.public token, or `undefined`. Pure. */
135
+ export function attestationProblem(token) {
136
+ if (/\s|\p{Cc}/u.test(token)) {
137
+ return "the attestation contains whitespace or a control character; pass the token alone";
138
+ }
139
+ if (!token.startsWith("v4.public.")) {
140
+ return "the attestation is not a PASETO v4.public token (it must start with 'v4.public.')";
141
+ }
142
+ if (token.length === "v4.public.".length) {
143
+ return "the attestation has no payload after 'v4.public.'";
144
+ }
145
+ return undefined;
146
+ }
147
+ /** The verdicts under which a posted payment is allowed. */
148
+ const ALLOWING = new Set(["Permitted", "ObligatedPending", "ObligatedFulfilled"]);
149
+ /**
150
+ * The answer an outcome gives, or `undefined` for one no agent payment can produce (a
151
+ * posted `ForbiddenRecorded`, an unposted allowing verdict, or a refusal that does not
152
+ * say why). Pure; fails closed: the refusal is always the service's, never invented.
153
+ */
154
+ export function judge(outcome) {
155
+ const receipt = outcome.receipt;
156
+ const replayed = outcome.replayed === true;
157
+ if (outcome.posted) {
158
+ return ALLOWING.has(outcome.verdict)
159
+ ? {
160
+ allowed: true,
161
+ transaction: outcome.transaction,
162
+ obligations: outcome.obligations_pending,
163
+ receipt,
164
+ replayed,
165
+ outcome,
166
+ }
167
+ : undefined;
168
+ }
169
+ if (outcome.verdict !== "ForbiddenRejected" || outcome.refusal === undefined) {
170
+ return undefined;
171
+ }
172
+ return {
173
+ allowed: false,
174
+ transaction: outcome.transaction,
175
+ refusal: outcome.refusal,
176
+ receipt,
177
+ replayed,
178
+ outcome,
179
+ };
180
+ }
181
+ /**
182
+ * Whether the justification recorded for this answer's transaction is `text`: its
183
+ * SHA-256 equals the answer's `justification_hash`. On a replay the recorded
184
+ * justification is the one the first submission carried, so a re-drive that reworded
185
+ * it resolves `false` here. Async only because it hashes with WebCrypto.
186
+ *
187
+ * ```ts
188
+ * const answer = await axorum.authorize(buyer, order);
189
+ * if (answer.replayed && !(await justificationMatches(answer, order.justification))) {
190
+ * log.warn("the ledger kept the first justification, not this one");
191
+ * }
192
+ * ```
193
+ */
194
+ export async function justificationMatches(answer, text) {
195
+ return (await justificationHash(text)) === answer.outcome.justification_hash;
196
+ }
197
+ /** The lowercase-hex SHA-256 of `text`'s UTF-8 bytes, as the service hashes it. */
198
+ export async function justificationHash(text) {
199
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
200
+ return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
201
+ }
202
+ /**
203
+ * What a {@link AuthorizeFailure | `not_submitted`} failure always says: this call sent
204
+ * nothing, but an earlier ambiguous one may still land.
205
+ */
206
+ export const NOT_SUBMITTED_CAVEAT = "If an earlier call with this key was ambiguous, the transaction may still commit; retry with the same key and the server will replay.";
207
+ /** How many policy rotations one call absorbs by re-pinning. */
208
+ export const MAX_AUTHORIZE_PINS = 3;
209
+ /**
210
+ * An authorize call that got no answer. Carries the payment's transaction id, which is
211
+ * what makes recovery safe: re-drive the **same** {@link Payment} and a payment that
212
+ * already committed replays its answer instead of paying twice.
213
+ *
214
+ * ```ts
215
+ * try {
216
+ * const answer = await axorum.authorize(buyer, lunch);
217
+ * } catch (error) {
218
+ * if (error instanceof AuthorizeError && error.retryable) {
219
+ * queue.later(lunch); // same Payment, same transaction id
220
+ * } else if (error instanceof AuthorizeError && error.needsFreshAttestation) {
221
+ * await refreshToken();
222
+ * } else {
223
+ * throw error;
224
+ * }
225
+ * }
226
+ * ```
227
+ */
228
+ export class AuthorizeError extends AxorumError {
229
+ code = "authorize";
230
+ /** The payment's transaction id. */
231
+ transaction;
232
+ /** What went wrong. */
233
+ failure;
234
+ /** The underlying failure, when there was one. */
235
+ cause;
236
+ constructor(transaction, failure, cause) {
237
+ super(`authorize ${transaction}: ${describeFailure(failure, cause)}`, cause instanceof AxorumError ? cause.status : null, cause instanceof AxorumError ? cause.body : null);
238
+ this.transaction = transaction;
239
+ this.failure = failure;
240
+ this.cause = cause;
241
+ }
242
+ /** The failure's kind, for a `switch`. */
243
+ get kind() {
244
+ return this.failure.kind;
245
+ }
246
+ /**
247
+ * Whether re-driving the same payment later may get an answer: after `indeterminate`,
248
+ * when the answer was not received, and after `not_submitted`, when this call
249
+ * submitted nothing. Every other kind fails identically on a re-drive until something
250
+ * outside the call changes.
251
+ */
252
+ get retryable() {
253
+ return this.failure.kind === "indeterminate" || this.failure.kind === "not_submitted";
254
+ }
255
+ /** Whether the fix is a fresh attestation. */
256
+ get needsFreshAttestation() {
257
+ return this.failure.kind === "attestation_rejected" || this.failure.kind === "attestation_expiring";
258
+ }
259
+ }
260
+ /** What each failure means and what to do next. Pure. */
261
+ function describeFailure(failure, cause) {
262
+ const said = cause instanceof Error ? ` (${cause.message})` : "";
263
+ switch (failure.kind) {
264
+ case "attestation_rejected":
265
+ return (`the service rejected the attestation${said}; obtain a fresh token from your issuer and ` +
266
+ `re-drive the same payment, which replays the answer if an earlier send of it committed`);
267
+ case "attestation_unavailable":
268
+ return `the attestation source failed${said}; nothing was sent. Fix the source, then re-drive the same payment`;
269
+ case "attestation_expiring":
270
+ return (`the attestation expires at ${failure.expiresAt}, less than ` +
271
+ `${ATTESTATION_MIN_REMAINING_MS / 1000} s from now, and no fresher token was available; ` +
272
+ `nothing was sent. Issue tokens valid for at least 5 minutes (15 to 60 is typical), check ` +
273
+ `this host's clock against NTP, then re-drive the same payment`);
274
+ case "unknown_agent":
275
+ return `no party is bound to this agent${said}; ask your operator to bind the agent:// URI, or check it for a typo`;
276
+ case "no_active_policy":
277
+ return `no policy is in force${said}; an operator must activate one before any payment can be judged`;
278
+ case "currency_not_declared":
279
+ return (`${failure.currency} is not a currency this deployment declared${said}; nothing was recorded. ` +
280
+ `An operator must declare it (\`axorum currency declare --code ${failure.currency} --exponent ` +
281
+ `<minor units>\`, or [[currencies]] in the serve config), then open the default chart in it`);
282
+ case "chart_not_provisioned":
283
+ return (`the default chart is not open in ${failure.currency}${said}; nothing was recorded. An ` +
284
+ `operator must open it (\`axorum chart provision --currency ${failure.currency}\`, or ` +
285
+ `[default_chart] in the serve config)`);
286
+ case "unknown_payee":
287
+ return (`the payee ${failure.payee} is not in the counterparty registry the policy in force reads${said}; ` +
288
+ `nothing was recorded. An operator must import it with approved: false to have it refused, or ` +
289
+ `approved: true to allow it`);
290
+ case "policy_contract":
291
+ return `the policy in force does not accept this payment's shape${said}; it must declare \`${DEFAULT_CHART_V1.declaration}\``;
292
+ case "transaction_reused":
293
+ return (`this transaction id already committed with different content${said}; nothing was written. ` +
294
+ `Create a new payment for a new payment, or re-drive the original unchanged`);
295
+ case "key_window_unstated":
296
+ return ((failure.reason === "malformed"
297
+ ? `the service's \`${failure.header}\` header carries ${JSON.stringify(failure.value)}, ` +
298
+ "which states no idempotency window"
299
+ : "the service states no idempotency window (its writer could not answer)") +
300
+ ", so a key from your records cannot be aged against it; nothing was sent. Read the " +
301
+ "transaction to learn whether it landed, and authorize a new payment only if it did not, " +
302
+ "or retry once the service is healthy");
303
+ case "stale_key":
304
+ return failure.reason === "unclocked"
305
+ ? `the service runs no heartbeat (tick_interval_ms is 0), so its idempotency window ` +
306
+ `(${failure.windowTicks} ticks) does not follow the wall clock and vouches for no key ` +
307
+ "from your records; nothing was sent. Reconcile first: read the transaction to learn " +
308
+ "whether it landed, and authorize a new payment only if it did not. To resend a key " +
309
+ "from your records, ask the service's operator to re-enable its heartbeat " +
310
+ "(tick_interval_ms above 0)"
311
+ : `this payment's key was minted ${Math.floor((failure.ageMs ?? 0) / 1000)} s ago, past the ` +
312
+ `service's ${failure.windowSeconds} s idempotency window, so the ledger may have ` +
313
+ "forgotten it and would apply it again; nothing was sent. Read the transaction to " +
314
+ "learn whether it landed, and authorize a new payment only if it did not";
315
+ case "keyed_for_another_agent":
316
+ return (`this payment's key was derived for ${failure.keyedFor}, not ${failure.agent}; nothing was ` +
317
+ `sent. Build the payment with keyedPayment for the agent that pays`);
318
+ case "capacity":
319
+ return failure.retryAfterMs === null
320
+ ? `the ledger is at a capacity bound${said}; an operator must raise it`
321
+ : `the ledger is at a capacity bound${said}; it may make room in ` +
322
+ `${Math.ceil(failure.retryAfterMs / 1000)} s, or an operator can raise it`;
323
+ case "stale_policy":
324
+ return (`the policy in force kept changing under the call (last pinned ${failure.pinned}, now ` +
325
+ `${failure.active}); nothing was written. Authorize again once policy rotation settles`);
326
+ case "indeterminate":
327
+ return (`the ledger's answer was not received (${describeCause(failure.lastCause)}); the payment may ` +
328
+ `or may not have committed. Re-drive the same payment to find out; it cannot pay twice`);
329
+ case "not_submitted":
330
+ return (`nothing from this call was submitted: ${describeUnsent(failure.cause)}` +
331
+ (failure.tried.length === 0 ? "" : ` (tried: ${failure.tried.map(describeAttempt).join(", ")})`) +
332
+ `; authorize the same Payment again. ${NOT_SUBMITTED_CAVEAT}`);
333
+ case "unexpected_verdict":
334
+ if (failure.outcome.verdict === "ForbiddenRejected" && failure.outcome.refusal === undefined) {
335
+ return ("the ledger refused the payment without saying why, which a service on the current " +
336
+ "contract never does; do not pay, and report it to your operator");
337
+ }
338
+ return (`the ledger answered with verdict ${failure.outcome.verdict} (posted: ${failure.outcome.posted}), ` +
339
+ `which no agent payment can produce; do not pay, and report it to your operator`);
340
+ case "rejected":
341
+ return (`the service refused the request (${failure.status} ${failure.body.error}` +
342
+ `${failure.body.cause === undefined ? "" : `, cause ${failure.body.cause}`}): ${failure.body.message}`);
343
+ case "undecodable":
344
+ return `the ledger answered but the answer did not decode${said}; it may have committed, so read the transaction back before paying`;
345
+ case "unexpected":
346
+ return (`a ${failure.status ?? "non-HTTP"} failure came back that is not from the Axorum service${said}; ` +
347
+ `check what sits between you and the service`);
348
+ }
349
+ }
350
+ /** One line for what a `not_submitted` call ended on, as the Rust SDK words it. Pure. */
351
+ function describeUnsent(cause) {
352
+ switch (cause.kind) {
353
+ case "connect":
354
+ return `no connection opened (${cause.message})`;
355
+ case "rate_limited":
356
+ return `429 rate limited (${cause.message})`;
357
+ case "unseeded":
358
+ return `the primary has not seeded its idempotency index (503 idempotency_unseeded: ${cause.message})`;
359
+ case "seed_refused":
360
+ return (`the ledger refused the primary's idempotency seed (500 idempotency_seed_refused: ` +
361
+ `${cause.message}); ask your operator to fail the primary over`);
362
+ case "not_normal":
363
+ return `the replica was in a view change or a recovery (503 not_normal: ${cause.message})`;
364
+ case "archive_pressure":
365
+ return (`the primary's local archive is behind (503 archive_pressure: ${cause.message}); ` +
366
+ `ask your operator to check the archiver`);
367
+ case "quarantined":
368
+ return (`the node is quarantined after a replay divergence (503 quarantined: ${cause.message}); ` +
369
+ `ask your operator to resolve the quarantine`);
370
+ case "proposal_refused":
371
+ return (`the node refused the proposal (500 proposal_refused: ${cause.message}); ` +
372
+ `ask your operator to check the node's logs`);
373
+ case "deadline_exceeded":
374
+ return `the time budget ran out ${Math.round(cause.elapsedMs)} ms in, before the payment was sent`;
375
+ case "other":
376
+ return cause.message;
377
+ }
378
+ }
379
+ /** One line for a cause. Pure. */
380
+ function describeCause(cause) {
381
+ switch (cause.kind) {
382
+ case "connect":
383
+ return `connect: ${cause.message}`;
384
+ case "rate_limited":
385
+ return `429 rate limited: ${cause.message}`;
386
+ case "transport":
387
+ return `transport: ${cause.message}`;
388
+ case "not_primary":
389
+ return `421 not_primary${cause.reason === null ? "" : ` (${cause.reason})`}`;
390
+ case "commit_unavailable":
391
+ return `503 commit_unavailable${cause.reason === null ? "" : ` (${cause.reason})`}`;
392
+ case "unavailable":
393
+ return cause.code === null ? `${cause.status} from the service or its proxy` : `${cause.status} ${cause.code}`;
394
+ case "deadline_exceeded":
395
+ return `the time budget ran out ${Math.round(cause.elapsedMs)} ms into the request, before it could be sent`;
396
+ case "earlier_attempt_in_doubt":
397
+ return (`an earlier attempt ended without an answer (${cause.earlier}) and may have committed; ` +
398
+ `the call then ended with: ${cause.last}`);
399
+ }
400
+ }
401
+ /**
402
+ * The `422` cause that means the default chart is not open in the payment's currency.
403
+ * Typed against {@link KnownSubstrateCause}, so a token the contract does not document
404
+ * does not compile.
405
+ */
406
+ const CHART_NOT_PROVISIONED_CAUSE = "unknown_account";
407
+ /** The `422` cause that means the payment's currency was never declared. */
408
+ const CURRENCY_NOT_DECLARED_CAUSE = "undeclared_currency";
409
+ /** The `422` cause that means the payee is not in the counterparty registry. */
410
+ const UNKNOWN_PAYEE_CAUSE = "unknown_counterparty";
411
+ /** The `422` causes that mean the policy does not declare the payment's action. */
412
+ const POLICY_CONTRACT_CAUSES = [
413
+ "undeclared_action",
414
+ "missing_param",
415
+ "unexpected_param",
416
+ "param_type_mismatch",
417
+ ];
418
+ /**
419
+ * Decides what to do about a failed request. Pure: the whole table.
420
+ *
421
+ * Re-pinned: a `409` stale pin. Unanswered (`indeterminate`): transport failures, `421`,
422
+ * `502`/`503`/`504`, and a request the deadline left no time to send. Everything else is
423
+ * a typed failure.
424
+ */
425
+ export function classify(error, pay) {
426
+ const fail = (failure) => ({ action: "fail", failure });
427
+ if (error instanceof EndpointsExhaustedError) {
428
+ return classify(error.last, pay);
429
+ }
430
+ if (error instanceof ConnectError) {
431
+ return { action: "unanswered", cause: { kind: "connect", message: error.message } };
432
+ }
433
+ if (error instanceof RateLimitedError) {
434
+ return { action: "unanswered", cause: { kind: "rate_limited", message: error.detail } };
435
+ }
436
+ if (error instanceof TransportError) {
437
+ return { action: "unanswered", cause: { kind: "transport", message: error.message } };
438
+ }
439
+ if (error instanceof DeadlineExceededError) {
440
+ return { action: "unanswered", cause: { kind: "deadline_exceeded", elapsedMs: error.elapsedMs } };
441
+ }
442
+ if (error instanceof NotPrimaryError) {
443
+ return { action: "unanswered", cause: { kind: "not_primary", reason: error.reason } };
444
+ }
445
+ if (error instanceof CommitUnavailableError) {
446
+ return { action: "unanswered", cause: { kind: "commit_unavailable", reason: error.reason } };
447
+ }
448
+ if (error instanceof StalePolicyError) {
449
+ return { action: "repin", active: error.active, pinned: error.pinned };
450
+ }
451
+ if (error instanceof TransactionReusedError) {
452
+ return fail({
453
+ kind: "transaction_reused",
454
+ storedContentHash: error.storedContentHash ?? null,
455
+ submittedContentHash: error.submittedContentHash ?? null,
456
+ });
457
+ }
458
+ if (error instanceof NoActivePolicyError) {
459
+ return fail({ kind: "no_active_policy" });
460
+ }
461
+ if (error instanceof UnknownAgentError) {
462
+ return fail({ kind: "unknown_agent" });
463
+ }
464
+ if (error instanceof AttestationError) {
465
+ return fail({ kind: "attestation_rejected" });
466
+ }
467
+ if (error instanceof DecodeError) {
468
+ return fail({ kind: "undecodable" });
469
+ }
470
+ if (error instanceof UnexpectedResponseError) {
471
+ return error.status !== null && error.status >= 502 && error.status <= 504
472
+ ? { action: "unanswered", cause: { kind: "unavailable", status: error.status, code: null } }
473
+ : fail({ kind: "unexpected", status: error.status });
474
+ }
475
+ if (error instanceof ApiError) {
476
+ return classifyBody(error.status ?? 0, error.body, error.retryAfterMs, pay);
477
+ }
478
+ return fail({ kind: "unexpected", status: error instanceof AxorumError ? error.status : null });
479
+ }
480
+ /** The documented error bodies with no dedicated class. Pure. */
481
+ function classifyBody(status, body, retryAfterMs, pay) {
482
+ const fail = (failure) => ({ action: "fail", failure });
483
+ const cause = body.cause;
484
+ if (status === 409 && body.error === "transaction_reused") {
485
+ return fail({
486
+ kind: "transaction_reused",
487
+ storedContentHash: body.stored_content_hash ?? null,
488
+ submittedContentHash: body.submitted_content_hash ?? null,
489
+ });
490
+ }
491
+ if (status === 507) {
492
+ return fail({ kind: "capacity", retryAfterMs });
493
+ }
494
+ if (status >= 502 && status <= 504) {
495
+ return { action: "unanswered", cause: { kind: "unavailable", status, code: body.error } };
496
+ }
497
+ if (status === 422 && cause !== undefined && isKnownSubstrateCause(cause)) {
498
+ if (cause === CURRENCY_NOT_DECLARED_CAUSE) {
499
+ return fail({ kind: "currency_not_declared", currency: pay.amount.currency });
500
+ }
501
+ if (cause === CHART_NOT_PROVISIONED_CAUSE) {
502
+ return fail({ kind: "chart_not_provisioned", currency: pay.amount.currency });
503
+ }
504
+ if (cause === UNKNOWN_PAYEE_CAUSE) {
505
+ return fail({ kind: "unknown_payee", payee: pay.payee });
506
+ }
507
+ if (POLICY_CONTRACT_CAUSES.includes(cause)) {
508
+ return fail({ kind: "policy_contract" });
509
+ }
510
+ }
511
+ return fail({ kind: "rejected", status, body });
512
+ }
513
+ /**
514
+ * The cause of a `not_submitted` call that ended on `error`, or `undefined` when `error`
515
+ * does not prove its attempt {@link AxorumError.wroteNothing | wrote nothing}. A call
516
+ * that moved between URLs ended on its last attempt's failure. Pure.
517
+ */
518
+ export function unsent(error) {
519
+ const last = error instanceof EndpointsExhaustedError ? error.last : error;
520
+ if (!(last instanceof AxorumError) || !last.wroteNothing) {
521
+ return undefined;
522
+ }
523
+ if (last instanceof ConnectError) {
524
+ return { kind: "connect", message: last.message };
525
+ }
526
+ if (last instanceof RateLimitedError) {
527
+ return { kind: "rate_limited", message: last.detail };
528
+ }
529
+ if (last instanceof DeadlineExceededError) {
530
+ return { kind: "deadline_exceeded", elapsedMs: last.elapsedMs };
531
+ }
532
+ if (last instanceof ApiError && last.code === "idempotency_unseeded") {
533
+ return { kind: "unseeded", message: last.body.message };
534
+ }
535
+ if (last instanceof ApiError && last.code === "idempotency_seed_refused") {
536
+ return { kind: "seed_refused", message: last.body.message };
537
+ }
538
+ if (last instanceof ApiError && last.code === "not_normal") {
539
+ return { kind: "not_normal", message: last.body.message };
540
+ }
541
+ if (last instanceof ApiError && last.code === "archive_pressure") {
542
+ return { kind: "archive_pressure", message: last.body.message };
543
+ }
544
+ if (last instanceof ApiError && last.code === "quarantined") {
545
+ return { kind: "quarantined", message: last.body.message };
546
+ }
547
+ if (last instanceof ApiError && last.code === "proposal_refused") {
548
+ return { kind: "proposal_refused", message: last.body.message };
549
+ }
550
+ return { kind: "other", message: last.message };
551
+ }
552
+ /**
553
+ * What to do about a failed request of the call, given the first attempt of the call
554
+ * left in doubt, if any, and every attempt `tried`, the last included: `not_submitted`
555
+ * exactly when `error` wrote nothing ({@link unsent}) and nothing was in doubt;
556
+ * {@link classify} otherwise, which the caller reads {@link afterDoubt}. Pure.
557
+ */
558
+ export function dispose(error, pay, earlier, tried) {
559
+ const cause = earlier === undefined ? unsent(error) : undefined;
560
+ return cause === undefined
561
+ ? classify(error, pay)
562
+ : { action: "fail", failure: { kind: "not_submitted", tried, cause } };
563
+ }
564
+ /**
565
+ * How a call ends on a request that failed with `error`, given the first attempt of the
566
+ * call left in doubt, if any, and every attempt `tried`, the last included: its
567
+ * {@link dispose | disposition}, a failure read {@link afterDoubt | after the doubt}.
568
+ * Pure.
569
+ */
570
+ export function conclude(error, pay, earlier, tried) {
571
+ const disposition = dispose(error, pay, earlier, tried);
572
+ switch (disposition.action) {
573
+ case "fail":
574
+ return afterDoubt(disposition.failure, earlier, error);
575
+ case "unanswered":
576
+ return afterDoubt({ kind: "indeterminate", lastCause: disposition.cause }, earlier, error);
577
+ case "repin":
578
+ return afterDoubt({ kind: "stale_policy", pinned: disposition.pinned, active: disposition.active }, earlier, error);
579
+ }
580
+ }
581
+ /**
582
+ * The failure an earlier attempt stands for, as far as the retry loop shows it: a
583
+ * re-sent status arrives without its body, so it reads as a body this client cannot
584
+ * vouch for ({@link UnexpectedResponseError}). Pure.
585
+ */
586
+ function seen(reason) {
587
+ switch (reason.kind) {
588
+ case "connect":
589
+ return new ConnectError(retryLabel(reason));
590
+ case "status":
591
+ return new UnexpectedResponseError(reason.status, "");
592
+ case "timeout":
593
+ return new TransportError(retryLabel(reason));
594
+ }
595
+ }
596
+ /**
597
+ * Whether an attempt the transport sent again after `reason` may have committed: unless
598
+ * what it saw {@link AxorumError.wroteNothing | wrote nothing}. Only a connection that
599
+ * never opened is definite: a re-sent status arrives without its body, so even a `429`,
600
+ * a `421` or a `507` cannot show the code that would make it definite. Every other
601
+ * outcome, a status this version does not know included, is doubt. Pure; the same rule
602
+ * as the Rust SDK.
603
+ */
604
+ export function leavesDoubt(reason) {
605
+ return !seen(reason).wroteNothing;
606
+ }
607
+ /**
608
+ * The `422 substrate_rejected` causes the service decides **before** it looks the
609
+ * transaction id up, so a re-sent submission whose first attempt committed can still be
610
+ * refused with them: `undeclared_currency` (the gateway's currency admission) and
611
+ * `amount_overflow` (the gateway's mandate-charge derivation).
612
+ */
613
+ const DECIDED_BEFORE_REPLAY = ["undeclared_currency", "amount_overflow"];
614
+ /**
615
+ * Whether `error` is the ledger's own answer about this transaction id, given after it
616
+ * looked the id up: `409 transaction_reused`, or a `422 substrate_rejected` whose cause
617
+ * is decided after that lookup (every cause but `undeclared_currency` and
618
+ * `amount_overflow`; `unknown_account`, `unknown_counterparty` and the action-contract
619
+ * causes are among them). Had an earlier attempt committed, the service would have
620
+ * answered with its replay instead, so the answer stands after an attempt in doubt.
621
+ * Pure.
622
+ */
623
+ export function settlesTheId(error) {
624
+ if (error instanceof TransactionReusedError) {
625
+ return true;
626
+ }
627
+ if (!(error instanceof ApiError)) {
628
+ return false;
629
+ }
630
+ if (error.status === 409) {
631
+ return error.code === "transaction_reused";
632
+ }
633
+ const cause = error.body.cause;
634
+ return (error.status === 422 &&
635
+ error.code === "substrate_rejected" &&
636
+ (cause === undefined || !DECIDED_BEFORE_REPLAY.includes(cause)));
637
+ }
638
+ /**
639
+ * `failure`, given the first attempt of the call left in doubt, if any, and the error it
640
+ * came from. Once an earlier attempt is in doubt, an end that read alone says the payment
641
+ * did not land becomes `indeterminate`, cause `earlier_attempt_in_doubt`: a failure whose
642
+ * error does not {@link settlesTheId | settle the id}, and an `indeterminate` end whose
643
+ * error {@link AxorumError.wroteNothing | wrote nothing}. A re-pin, and an
644
+ * `indeterminate` end that is itself in doubt, are unchanged. Pure.
645
+ */
646
+ export function afterDoubt(failure, earlier, cause) {
647
+ if (earlier === undefined || settlesTheId(cause)) {
648
+ return failure;
649
+ }
650
+ if (failure.kind === "indeterminate") {
651
+ if (!(cause instanceof AxorumError) || !cause.wroteNothing) {
652
+ return failure;
653
+ }
654
+ return {
655
+ kind: "indeterminate",
656
+ lastCause: { kind: "earlier_attempt_in_doubt", earlier, last: describeCause(failure.lastCause) },
657
+ };
658
+ }
659
+ return {
660
+ kind: "indeterminate",
661
+ lastCause: { kind: "earlier_attempt_in_doubt", earlier, last: describeFailure(failure, cause) },
662
+ };
663
+ }
664
+ /**
665
+ * Whether the call sends a submission again after `error` itself, rather than through
666
+ * the transport: every `503`, read with its body. The transport re-sends on the status
667
+ * alone and cannot tell a `503 idempotency_unseeded`, which wrote nothing, from a
668
+ * `503 commit_unavailable`, which may commit. Pure.
669
+ */
670
+ export function redrives(error) {
671
+ return (error instanceof CommitUnavailableError ||
672
+ ((error instanceof ApiError || error instanceof UnexpectedResponseError) && error.status === 503));
673
+ }
674
+ /**
675
+ * The pause before the call sends a submission again for the `redriven + 1`th time, under
676
+ * {@link AUTHORIZE_RETRY_POLICY} with `draw`, or `undefined` when it would reach the
677
+ * deadline `remainingMs` away. Pure.
678
+ */
679
+ export function redrivePause(redriven, draw, remainingMs) {
680
+ return pause(AUTHORIZE_RETRY_POLICY, redriven + 1, draw, remainingMs);
681
+ }
682
+ /**
683
+ * What the call ends on when the send after a re-drive pause fails with `error`: the
684
+ * `503` it paused after, `pausedAfter`, when the budget ran out before that send could
685
+ * leave (a deadline with no attempt), since the call's last answer is still that `503`;
686
+ * else `error`. The `503` was already heard as re-driven, so the caller lists it once.
687
+ * Pure.
688
+ */
689
+ export function endedAfterRedrive(pausedAfter, error) {
690
+ return pausedAfter !== undefined && error instanceof DeadlineExceededError && error.attempts === 0
691
+ ? { error: pausedAfter, heard: true }
692
+ : { error, heard: false };
693
+ }
694
+ /** What one attempt that ended with `error` is listed as, or `undefined` when it has no status or connect outcome. Pure. */
695
+ function outcomeOf(error) {
696
+ if (error instanceof ConnectError) {
697
+ return { kind: "connect" };
698
+ }
699
+ return error.status === null ? undefined : { kind: "status", status: error.status };
700
+ }
701
+ /**
702
+ * The attempt a submission's `error` ended on, as `tried` lists it: the last of a call
703
+ * that moved between URLs, else the one at `endpoint`; `undefined` when nothing was sent
704
+ * or no endpoint is known. Pure.
705
+ */
706
+ export function finalAttempt(error, endpoint) {
707
+ if (error instanceof EndpointsExhaustedError) {
708
+ return error.attempts.at(-1);
709
+ }
710
+ if (!(error instanceof AxorumError) || endpoint === undefined) {
711
+ return undefined;
712
+ }
713
+ const outcome = outcomeOf(error);
714
+ return outcome === undefined ? undefined : { endpoint, outcome, rotated: false };
715
+ }
716
+ /**
717
+ * Every attempt a read of the call (the pin, the window) that failed with `error` made:
718
+ * those of a read that moved between URLs, else none, since no recorder hears a read.
719
+ * Pure.
720
+ */
721
+ export function attemptsOf(error) {
722
+ return error instanceof EndpointsExhaustedError ? error.attempts : [];
723
+ }
724
+ /**
725
+ * The recorder of one call: hears every attempt its submissions are sent again after,
726
+ * from the transport (a re-sent status, by its status alone) and from the call (a `503`,
727
+ * its body read), and keeps them in order, and the first in doubt. Shared by every
728
+ * submission of the call, so doubt carries across re-pins. The Rust SDK's `Doubt`.
729
+ */
730
+ export class Doubt {
731
+ #tried = [];
732
+ #earlier;
733
+ /** The first outcome in doubt, by its label (`"503"`, `"timeout"`), or `undefined`. */
734
+ get earlier() {
735
+ return this.#earlier;
736
+ }
737
+ /** Every attempt heard so far, in order. */
738
+ get tried() {
739
+ return [...this.#tried];
740
+ }
741
+ /** Hears an attempt the transport sent again: in doubt unless its connection never opened. */
742
+ resent(attempt) {
743
+ this.#keep(attempt, leavesDoubt(attempt.outcome));
744
+ }
745
+ /** Hears a submission the call sent again after reading its `error`: in doubt unless it wrote nothing. */
746
+ redriven(endpoint, error) {
747
+ const outcome = outcomeOf(error) ?? { kind: "status", status: 503 };
748
+ this.#keep({ endpoint, outcome, rotated: false }, !error.wroteNothing);
749
+ }
750
+ #keep(attempt, doubt) {
751
+ this.#tried.push(attempt);
752
+ if (doubt && this.#earlier === undefined) {
753
+ this.#earlier = rotationLabel(attempt.outcome);
754
+ }
755
+ }
756
+ }
757
+ /**
758
+ * Why a keyed payment must not be sent, or `undefined` to go ahead: the pure half of the
759
+ * key gate. A payment keyed for another agent would name that agent's transaction.
760
+ */
761
+ export function keyGate(agentUri, pay) {
762
+ if (pay.keyedFor === undefined || pay.keyedFor === agentUri) {
763
+ return undefined;
764
+ }
765
+ return { kind: "keyed_for_another_agent", keyedFor: pay.keyedFor, agent: agentUri };
766
+ }
767
+ /**
768
+ * Whether a refusal from the client's kept copy of the window is read again before it
769
+ * stands: only a stale key, and only when the copy was kept rather than just read. The
770
+ * window never shrinks, so a copy that covers the key needs no second look, and one
771
+ * that refuses it may be out of date because the window grew.
772
+ */
773
+ export function rereads(kept, gated) {
774
+ return kept && gated?.kind === "stale_key";
775
+ }
776
+ /**
777
+ * Whether a key may be sent at `nowMs` under the service's window, as its headers stated
778
+ * it (an {@link IdempotencyWindowError} when they stated none): `undefined` to send,
779
+ * else why not. A key minted in this process (`held` false) was never sent before and
780
+ * always goes ahead; one from the caller's records goes only while the window covers
781
+ * it. Pure; `spec/fixtures/key-gate-v1.json` pins it in both SDKs.
782
+ */
783
+ export function vouch(held, key, window, nowMs) {
784
+ if (!held) {
785
+ return undefined;
786
+ }
787
+ if (window instanceof IdempotencyWindowError) {
788
+ return { kind: "key_window_unstated", reason: window.reason, header: window.header, value: window.value };
789
+ }
790
+ try {
791
+ ensureFresh(key, window, nowMs);
792
+ }
793
+ catch (error) {
794
+ if (error instanceof StaleIdempotencyKeyError) {
795
+ return {
796
+ kind: "stale_key",
797
+ reason: error.reason,
798
+ ageMs: error.ageMs,
799
+ windowSeconds: error.windowSeconds,
800
+ windowTicks: error.windowTicks,
801
+ };
802
+ }
803
+ throw error;
804
+ }
805
+ return undefined;
806
+ }
807
+ /**
808
+ * The pin loop: the only part of `authorize` that does I/O. Pins (from the shared cache,
809
+ * or by reading the active policy), submits, and re-pins at most
810
+ * {@link MAX_AUTHORIZE_PINS} times. It owns no transport retry; the client's lane holds
811
+ * every request to the call's deadline.
812
+ *
813
+ * @param caller - The caller's own cancellation. When it fires, its reason is thrown
814
+ * as-is, never dressed up as a ledger failure.
815
+ */
816
+ export async function runAuthorize(transport, agent, pay, caller) {
817
+ const draft = await planPayment(agent.uri, "", pay);
818
+ const fail = (failure, cause) => {
819
+ if (failure.kind === "no_active_policy") {
820
+ transport.pin.value = undefined;
821
+ }
822
+ throw new AuthorizeError(pay.transaction, failure, cause);
823
+ };
824
+ // Every submission of the call reports its attempts here, so doubt carries across
825
+ // re-pins.
826
+ const doubt = new Doubt();
827
+ const settle = (error, tried) => {
828
+ if (caller?.aborted === true) {
829
+ throw caller.reason;
830
+ }
831
+ return fail(conclude(error, pay, doubt.earlier, tried), error);
832
+ };
833
+ // Whether a key from the caller's records may leave the process now: the service's
834
+ // window, read once per client, must still cover it on this clock. A minted key goes
835
+ // ahead without a read. `undefined` to send; a failed read settles like any other.
836
+ // One read of the window, as `vouch` takes it; a failed read is returned as thrown.
837
+ const read = async () => {
838
+ try {
839
+ return { window: await transport.readWindow(caller) };
840
+ }
841
+ catch (error) {
842
+ return error instanceof IdempotencyWindowError ? { window: error } : { failed: error };
843
+ }
844
+ };
845
+ const hold = async () => {
846
+ // A minted key needs no read: it is never held back.
847
+ if (pay.origin === "minted") {
848
+ return undefined;
849
+ }
850
+ const kept = transport.keptWindow();
851
+ let window;
852
+ if (kept === undefined) {
853
+ const first = await read();
854
+ if ("failed" in first) {
855
+ return settle(first.failed, [...doubt.tried, ...attemptsOf(first.failed)]);
856
+ }
857
+ window = first.window;
858
+ }
859
+ else {
860
+ window = kept;
861
+ }
862
+ const gated = vouch(true, pay.idempotencyKey, window, Date.now());
863
+ if (!rereads(kept !== undefined, gated)) {
864
+ return gated;
865
+ }
866
+ // The window may have grown since the copy was taken: read it again once.
867
+ const again = await read();
868
+ if ("failed" in again) {
869
+ // The read failed: the kept copy's refusal stands, and nothing is sent.
870
+ return gated;
871
+ }
872
+ return vouch(true, pay.idempotencyKey, again.window, Date.now());
873
+ };
874
+ const refusedLocally = keyGate(agent.uri, pay);
875
+ if (refusedLocally !== undefined) {
876
+ return fail(refusedLocally, undefined);
877
+ }
878
+ const held = await hold();
879
+ if (held !== undefined) {
880
+ return fail(held, undefined);
881
+ }
882
+ let pinned = transport.pin.value;
883
+ if (pinned === undefined) {
884
+ let active;
885
+ try {
886
+ active = await transport.activePolicy(caller);
887
+ }
888
+ catch (error) {
889
+ return settle(error, attemptsOf(error));
890
+ }
891
+ if (active === null) {
892
+ return fail({ kind: "no_active_policy" }, undefined);
893
+ }
894
+ transport.pin.value = active;
895
+ pinned = active;
896
+ }
897
+ for (let pins = 1;; pins += 1) {
898
+ // A re-pinned send is held to the window again: the key has aged since the first,
899
+ // and the window read is the client's kept one.
900
+ if (pins > 1) {
901
+ const regated = await hold();
902
+ if (regated !== undefined) {
903
+ return fail(afterDoubt(regated, doubt.earlier), undefined);
904
+ }
905
+ }
906
+ const token = await agent.attestation(pay.transaction);
907
+ // The deadline is taken from one wall-clock read and held on the monotonic clock.
908
+ const expMs = attestationExpiryMs(token);
909
+ if (typeof expMs === "string") {
910
+ return fail({ kind: "attestation_unavailable" }, new InvalidAgentError(expMs));
911
+ }
912
+ const deadline = submissionDeadline(expMs, Date.now(), performance.now());
913
+ if (deadline === undefined) {
914
+ return fail({ kind: "attestation_expiring", expiresAt: formatUtcMs(expMs) }, undefined);
915
+ }
916
+ // The plan carries the payment key's mint time, so the pin stamps it.
917
+ const envelope = pinIntent({ ...draft, attestation: token }, pinned);
918
+ let outcome;
919
+ let ended;
920
+ try {
921
+ outcome = await transport.submit(envelope, caller, {
922
+ deadline,
923
+ onResend: (attempt) => doubt.resent(attempt),
924
+ onRedrive: (endpoint, error) => doubt.redriven(endpoint, error),
925
+ onEnded: (endpoint) => {
926
+ ended = endpoint;
927
+ },
928
+ });
929
+ }
930
+ catch (error) {
931
+ const disposition = error instanceof StalePolicyError ? classify(error, pay) : undefined;
932
+ if (disposition?.action !== "repin" || pins >= MAX_AUTHORIZE_PINS) {
933
+ const last = finalAttempt(error, ended);
934
+ return settle(error, last === undefined ? doubt.tried : [...doubt.tried, last]);
935
+ }
936
+ transport.pin.value = disposition.active;
937
+ pinned = disposition.active;
938
+ continue;
939
+ }
940
+ const answer = judge(outcome);
941
+ if (answer === undefined) {
942
+ throw new AuthorizeError(pay.transaction, { kind: "unexpected_verdict", outcome });
943
+ }
944
+ return answer;
945
+ }
946
+ }
947
+ //# sourceMappingURL=authorize.js.map