@aaes-ai/govern-sdk 0.0.0-stage → 0.2.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.
package/aaes.mjs ADDED
@@ -0,0 +1,1722 @@
1
+ /**
2
+ * aaes - a thin, honest JavaScript/TypeScript client for the AAES HTTP
3
+ * contract. Dependency-free: the global fetch and nothing else.
4
+ *
5
+ * What it is
6
+ * ----------
7
+ * One client, one action: name a capability and an action, get the daemon's
8
+ * decision. The client holds a base URL, a bearer token and the caller's
9
+ * identity as the daemon stated it, and nothing else. No policy, no registry,
10
+ * no credential, no verification.
11
+ *
12
+ * What it deliberately does not do
13
+ * --------------------------------
14
+ * - No caller-supplied destination, method or credential. `action()` refuses any
15
+ * key it does not document, so a url, a method, a header or a token cannot be
16
+ * smuggled in; the endpoint and the token are configuration (the constructor
17
+ * or fromEnv). The payload is checked against the same field-name families
18
+ * the daemon refuses, so the mistake is caught before the request leaves the
19
+ * process.
20
+ * - No identity claim. The client asks the daemon who the caller is
21
+ * (GET /v1/capabilities, once per client) and fills tenant_id and actor_id
22
+ * from the daemon's own answer. Nothing lets you write a different tenant or
23
+ * actor.
24
+ * - No verification. Receipts, record hashes and grants are returned as the
25
+ * daemon's bytes; this module checks none of them and says so. Evidence is
26
+ * verified offline with AAES out of the trust path: verifyHint() renders the
27
+ * command, aaesctl verify --export ... --pubkey ...
28
+ * - No retry that could double an effect. A call that can produce an effect is
29
+ * retried only when you opted in (RetryPolicy), the daemon answered 503,
30
+ * and the identical call carries its original effect key
31
+ * the daemon committed the first attempt under, so a repeat is refused with
32
+ * 409 rather than performed twice. 409, 403, 401 and 400 are never retried.
33
+ * requestAccess carries no effect key on the daemon side, so it is never
34
+ * retried at all.
35
+ *
36
+ * A refusal is an answer
37
+ * ----------------------
38
+ * A decision the daemon refused is returned as a Decision with allowed false
39
+ * and refusal set: the daemon's reason VERBATIM plus the smallest change that
40
+ * would satisfy it, derived only from what the daemon itself published (the
41
+ * entitled slice, the requestable catalog). A thrown AaesError means no answer
42
+ * arrived at all.
43
+ *
44
+ * Usage
45
+ * -----
46
+ * import { Client } from "./aaes.mjs";
47
+ *
48
+ * const client = Client.fromEnv(); // AAES_ENDPOINT, AAES_TOKEN
49
+ * const decision = await client.action({
50
+ * capability: "payments.transfer",
51
+ * action: "invoke",
52
+ * taskId: "task-demo",
53
+ * effectKeyStep: "charge-invoice-4471",
54
+ * payload: { invoice_id: "4471" },
55
+ * });
56
+ * if (decision.ok) console.log(decision.grant.grantId, decision.receipt.recordHash);
57
+ * else console.log(String(decision.refusal)); // reason + smallest change
58
+ */
59
+
60
+ export const ENDPOINT_ENV = "AAES_ENDPOINT";
61
+ export const TOKEN_ENV = "AAES_TOKEN";
62
+
63
+ const PATH_ACTION = "/v1/action";
64
+ const PATH_RESUME = "/v1/action/resume";
65
+ const PATH_BROKERED = "/v1/brokered";
66
+ const PATH_BROKERED_RESUME = "/v1/brokered/resume";
67
+ const PATH_GRANT_PRESENT = "/v1/grants/present";
68
+ const PATH_FEDERATE = "/v1/federate";
69
+ const PATH_OBSERVE = "/v1/observe";
70
+ const PATH_CAPABILITIES = "/v1/capabilities";
71
+ const PATH_CATALOGUE = "/v1/catalogue";
72
+ const PATH_ACCESS_REQUESTS = "/v1/access-requests";
73
+ const PATH_HEALTHZ = "/healthz";
74
+ const PATH_READYZ = "/readyz";
75
+
76
+ const MAX_RESPONSE_BYTES = 4 << 20;
77
+ const DEFAULT_TIMEOUT_MS = 30_000;
78
+
79
+ /** Refusal advice reads only snapshots this client already holds, and a
80
+ * catalogue snapshot stops speaking for the catalogue after this window, the
81
+ * same sixty seconds the Python SDK uses. */
82
+ const CATALOGUE_SNAPSHOT_FRESH_MS = 60_000;
83
+
84
+ /** Statuses a resume route reads WITHOUT decoding: a 404 maps to a refusal
85
+ * whether the body is the daemon's JSON or an older daemon's (or a proxy's)
86
+ * plain text, so the mapping can never depend on a parse that already ran. */
87
+ const UNDECODED_404 = new Set([404]);
88
+
89
+ /** The frozen effect classes (internal/types/effect.go). Never recomputed here. */
90
+ export const EFFECT_DENIED = 0;
91
+ export const EFFECT_MINTED_UNUSED = 1;
92
+ export const EFFECT_EXECUTED = 2;
93
+ export const EFFECT_AUTHORIZED = 3;
94
+ export const EFFECT_DEFERRED = 4;
95
+
96
+ /** No answer arrived, or the answer could not be read. */
97
+ export class AaesError extends Error {}
98
+
99
+ /** The payload named a destination, a request shape or a credential. */
100
+ export class CallerRouteError extends AaesError {}
101
+
102
+ /**
103
+ * Your explicit opt-in for retry. The default means one attempt.
104
+ *
105
+ * The rule a policy can turn on, and nothing more: a read, or a call that
106
+ * presents an effect key, is repeated after a 503 - always presenting the SAME
107
+ * key, so the daemon refuses a duplicate with 409 instead of performing it
108
+ * twice. Anything else is attempted once.
109
+ */
110
+ export class RetryPolicy {
111
+ constructor({ maxAttempts = 1, baseDelayMs = 200 } = {}) {
112
+ this.maxAttempts = maxAttempts;
113
+ this.baseDelayMs = baseDelayMs;
114
+ }
115
+
116
+ /** Three attempts with a small backoff. Never the default: retrying is your
117
+ * decision and not this module's. */
118
+ static default() {
119
+ return new RetryPolicy({ maxAttempts: 3, baseDelayMs: 200 });
120
+ }
121
+
122
+ attempts(identified, answer503) {
123
+ if (this.maxAttempts <= 1 || !identified || answer503) return 1;
124
+ return this.maxAttempts;
125
+ }
126
+
127
+ delay(attempt) {
128
+ return this.baseDelayMs * 2 ** (attempt - 1);
129
+ }
130
+ }
131
+
132
+ /** A decision the daemon refused: its sentence VERBATIM, plus the smallest
133
+ * change that would satisfy it, derived only from what the daemon published. */
134
+ export class Refusal {
135
+ constructor(status, reason, advice = [], retryable = false) {
136
+ this.status = status;
137
+ this.reason = reason;
138
+ this.advice = advice;
139
+ this.retryable = retryable;
140
+ }
141
+
142
+ smallestChange() {
143
+ return this.advice.join("; ");
144
+ }
145
+
146
+ toString() {
147
+ const head = `refused with HTTP ${this.status}: ${this.reason}`;
148
+ return this.advice.length ? `${head}; smallest change that would satisfy it: ${this.advice.join("; ")}` : head;
149
+ }
150
+ }
151
+
152
+ /** A client decision. A refusal is an answer, not an exception. */
153
+ export class Decision {
154
+ constructor(fields = {}) {
155
+ this.allowed = false;
156
+ this.deferred = false;
157
+ this.reason = "";
158
+ this.intentId = "";
159
+ this.grant = null;
160
+ this.receipt = null;
161
+ this.latencyAddedMs = 0;
162
+ this.refusal = null;
163
+ Object.assign(this, fields);
164
+ }
165
+
166
+ get ok() {
167
+ return this.allowed && !this.deferred && this.refusal === null;
168
+ }
169
+ }
170
+
171
+ /** A federated call's request: ONE capability, ONE action and ONE task.
172
+ *
173
+ * There is deliberately no field for a URL, a destination, a method, a header or
174
+ * a credential. Unknown keys are REFUSED before a request is sent (see
175
+ * refuseUnknownKeys and FEDERATE_KEYS): the daemon resolves the target from the
176
+ * capability registration and the credential from its own custody. */
177
+ const FEDERATE_KEYS = new Set([
178
+ "capability", "action", "taskId", "effectKeyStep", "amountMinor", "amountCurrency", "grantTtlMs",
179
+ "rotate", "correlationId",
180
+ ]);
181
+
182
+ /** One STS request the WORKLOAD performs with its own identity. AAES built it
183
+ * and did not send it: the subject token is the workload's own. */
184
+ export class FederatedInstructions {
185
+ constructor(fields = {}) {
186
+ this.backend = "";
187
+ this.method = "";
188
+ this.url = "";
189
+ this.headers = {};
190
+ this.form = {};
191
+ this.json = {};
192
+ this.subjectTokenIn = "";
193
+ this.subjectTokenField = "";
194
+ this.subjectTokenType = "";
195
+ this.durationSeconds = 0;
196
+ this.documentation = "";
197
+ this.notBuilt = [];
198
+ this.notes = [];
199
+ Object.assign(this, fields);
200
+ }
201
+
202
+ toString() {
203
+ if (!this.method) return "instructions (none)";
204
+ return "instructions " + this.method + " " + this.url + " (subject token in " +
205
+ this.subjectTokenIn + " field " + JSON.stringify(this.subjectTokenField) + "; shape from " +
206
+ this.documentation + ")";
207
+ }
208
+
209
+ toJSON() {
210
+ return { backend: this.backend, method: this.method, url: this.url, subjectTokenField: this.subjectTokenField };
211
+ }
212
+ }
213
+
214
+ /** The credential, or the instructions to obtain it. It is the ONE value in this
215
+ * module that must never be logged: toString, toJSON and the Node inspector all
216
+ * REDACT it, and nothing else in this file prints it. */
217
+ export class FederatedMaterial {
218
+ constructor(fields = {}) {
219
+ this.kind = "";
220
+ this.credential = new Uint8Array(0);
221
+ this.instructions = null;
222
+ this.expiresAt = "";
223
+ this.audience = "";
224
+ Object.assign(this, fields);
225
+ }
226
+
227
+ toString() {
228
+ if (this.kind === "credential") {
229
+ return "federated material (credential, " + this.credential.length + " bytes, expires " +
230
+ (this.expiresAt || "no declared expiry") + ", REDACTED)";
231
+ }
232
+ if (this.kind === "instructions") {
233
+ return this.instructions === null
234
+ ? "federated material (instructions, none)"
235
+ : "federated material (" + this.instructions.toString() + ")";
236
+ }
237
+ return "federated material (none)";
238
+ }
239
+
240
+ toJSON() {
241
+ return { kind: this.kind, redacted: true, expiresAt: this.expiresAt, audience: this.audience };
242
+ }
243
+
244
+ [Symbol.for("nodejs.util.inspect.custom")]() {
245
+ return this.toString();
246
+ }
247
+ }
248
+
249
+ /** A federated grant: the material (or the instructions), the locator, the exact
250
+ * scope, the expiry and the intent id. The material is a FIELD and never appears
251
+ * in a render. */
252
+ export class FederatedGrant {
253
+ constructor(fields = {}) {
254
+ this.federationId = "";
255
+ this.intentId = "";
256
+ this.decisionGrantId = "";
257
+ this.tenantId = "";
258
+ this.actorId = "";
259
+ this.capability = "";
260
+ this.capabilityVersion = "";
261
+ this.actions = [];
262
+ this.resourceId = "";
263
+ this.taskId = "";
264
+ this.dataClass = "";
265
+ this.tier = "";
266
+ this.spendCapMinor = null;
267
+ this.spendCapMinorPresent = false;
268
+ this.spendCapCurrency = "";
269
+ this.scopeKey = "";
270
+ this.backend = "";
271
+ this.locator = { capability: "", endpoint: "", method: "", authority: "", resourceId: "" };
272
+ this.issuedAt = "";
273
+ this.expiresAt = "";
274
+ this.ttlSeconds = 0;
275
+ this.material = new FederatedMaterial();
276
+ this.record = { intentId: "", mode: "", payloadSeen: false, markerReceiptHash: "", receiptId: "" };
277
+ Object.assign(this, fields);
278
+ }
279
+
280
+ /** The exact authority the grant carries, for a caller that wants to log WHAT
281
+ * was authorized without logging the material. */
282
+ scope() {
283
+ return {
284
+ capability: this.capability,
285
+ actions: [...this.actions],
286
+ resourceId: this.resourceId,
287
+ dataClass: this.dataClass,
288
+ tier: this.tier,
289
+ spendCapMinor: this.spendCapMinor,
290
+ spendCapMinorPresent: this.spendCapMinorPresent,
291
+ spendCapCurrency: this.spendCapCurrency,
292
+ scopeKey: this.scopeKey,
293
+ };
294
+ }
295
+
296
+ toString() {
297
+ return "federated grant " + this.federationId + " for " + this.tenantId + "/" + this.actorId +
298
+ " capability=" + this.capability + " actions=" + JSON.stringify(this.actions) +
299
+ " resource=" + this.resourceId + " task=" + this.taskId + " expires=" + this.expiresAt +
300
+ " mode=" + this.record.mode + " material=" + this.material.toString();
301
+ }
302
+
303
+ toJSON() {
304
+ return {
305
+ federation_id: this.federationId,
306
+ intent_id: this.intentId,
307
+ decision_grant_id: this.decisionGrantId,
308
+ tenant_id: this.tenantId,
309
+ actor_id: this.actorId,
310
+ capability: this.capability,
311
+ actions: this.actions,
312
+ resource_id: this.resourceId,
313
+ task_id: this.taskId,
314
+ data_class: this.dataClass,
315
+ tier: this.tier,
316
+ scope_key: this.scopeKey,
317
+ backend: this.backend,
318
+ locator: this.locator,
319
+ issued_at: this.issuedAt,
320
+ expires_at: this.expiresAt,
321
+ ttl_seconds: this.ttlSeconds,
322
+ material: this.material.toJSON(),
323
+ record: this.record,
324
+ };
325
+ }
326
+
327
+ [Symbol.for("nodejs.util.inspect.custom")]() {
328
+ return this.toString();
329
+ }
330
+ }
331
+
332
+ // --- the payload tripwire --------------------------------------------------
333
+
334
+ const DESTINATION_FIELDS = new Set([
335
+ "url", "uri", "endpoint", "endpointurl", "endpointuri", "host", "hostname",
336
+ "hostport", "authority", "domain", "baseurl", "baseuri", "targeturl",
337
+ "targeturi", "destination", "destinationurl", "dest", "desturl", "proxy",
338
+ "proxyurl", "origin", "originurl", "server", "serverurl", "address", "addr",
339
+ "ip", "redirect", "redirecturl", "callback", "callbackurl", "webhook",
340
+ "webhookurl", "socket",
341
+ ]);
342
+ const SHAPE_FIELDS = new Set([
343
+ "method", "headers", "header", "httpmethod", "requestmethod", "verb",
344
+ "contenttype", "requestline", "queryparams",
345
+ ]);
346
+ const CREDENTIAL_FIELDS = new Set([
347
+ "authorization", "authorisation", "auth", "bearer", "authtoken",
348
+ "bearertoken", "authheader", "authvalue", "credential", "credentials",
349
+ "credentialref", "secret", "secrets", "secretref", "secretrefid",
350
+ "secretkey", "secretvalue", "token", "tokens", "accesstoken",
351
+ "refreshtoken", "idtoken", "apitoken", "sessiontoken", "tokenref",
352
+ "tokenid", "apikey", "apikeyid", "apikeysecret", "apisecret", "password",
353
+ "passwd", "pwd", "passphrase", "privatekey", "privatekeyid", "clientsecret",
354
+ "accesskey", "accesskeyid", "secretaccesskey", "signingkey", "signature",
355
+ "cookie", "cookies", "setcookie", "session", "sessionid", "sessionkey",
356
+ "userinfo", "usercredentials",
357
+ ]);
358
+
359
+ const MAX_PAYLOAD_NODES = 4096;
360
+ const MAX_PAYLOAD_DEPTH = 8;
361
+
362
+ function normalizeField(key) {
363
+ let out = "";
364
+ for (const ch of String(key).toLowerCase()) {
365
+ if ((ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9")) out += ch;
366
+ }
367
+ return out;
368
+ }
369
+
370
+ function classify(normalized) {
371
+ if (DESTINATION_FIELDS.has(normalized)) return "destination";
372
+ if (SHAPE_FIELDS.has(normalized)) return "request shape";
373
+ if (CREDENTIAL_FIELDS.has(normalized)) return "credential";
374
+ return "";
375
+ }
376
+
377
+ function forbiddenField(key) {
378
+ const normalized = normalizeField(key);
379
+ const kind = classify(normalized);
380
+ if (kind) return kind;
381
+ if (normalized.length > 1 && normalized[0] === "x") return classify(normalized.slice(1));
382
+ return "";
383
+ }
384
+
385
+ /**
386
+ * Refuse a payload that names a destination, a request shape or a credential.
387
+ * The daemon refuses one too (internal/gateway/broker.go, callerRouteGuard) and
388
+ * that check is the authoritative one; this one catches the mistake before a
389
+ * request leaves the process.
390
+ */
391
+ export function guardPayload(payload) {
392
+ if (payload === undefined || payload === null) return null;
393
+ if (typeof payload !== "object" || Array.isArray(payload)) {
394
+ throw new CallerRouteError(
395
+ `aaes: payload is a JSON ${jsonKind(payload)}, not an object; a payload carries the fields of one intent`,
396
+ );
397
+ }
398
+ scanPayload(payload, 0, [0]);
399
+ return payload;
400
+ }
401
+
402
+ function scanPayload(value, depth, nodes) {
403
+ nodes[0] += 1;
404
+ if (nodes[0] > MAX_PAYLOAD_NODES) throw new CallerRouteError("aaes: payload is too large to inspect; refusing");
405
+ if (depth > MAX_PAYLOAD_DEPTH) {
406
+ throw new CallerRouteError(`aaes: payload nests deeper than ${MAX_PAYLOAD_DEPTH} levels; refusing`);
407
+ }
408
+ if (typeof value === "number" && !Number.isFinite(value)) {
409
+ throw new CallerRouteError("aaes: payload numbers must be finite JSON values");
410
+ }
411
+ if (Array.isArray(value)) {
412
+ for (const child of value) scanPayload(child, depth + 1, nodes);
413
+ return;
414
+ }
415
+ if (value && typeof value === "object") {
416
+ for (const key of Object.keys(value).sort()) {
417
+ if (/[^\x00-\x7f]/.test(key)) {
418
+ throw new CallerRouteError(
419
+ `aaes: payload field ${JSON.stringify(key)} contains characters outside ASCII, so it cannot be compared with the names this client refuses; rename it in ASCII or remove it`,
420
+ );
421
+ }
422
+ const kind = forbiddenField(key);
423
+ if (kind) {
424
+ throw new CallerRouteError(
425
+ `aaes: payload names a ${kind} field ${JSON.stringify(key)}; the destination, the method and the credential come from the capability registration and from custody, never from the caller`,
426
+ );
427
+ }
428
+ scanPayload(value[key], depth + 1, nodes);
429
+ }
430
+ }
431
+ }
432
+
433
+ function jsonKind(value) {
434
+ if (value === null) return "null";
435
+ if (Array.isArray(value)) return "array";
436
+ return typeof value;
437
+ }
438
+
439
+ // --- the client ------------------------------------------------------------
440
+
441
+ /** The documented keys of one action. Anything else is refused before a request
442
+ * is sent, which is what keeps a url, a method or a credential out. */
443
+ const ACTION_KEYS = new Set([
444
+ "capability", "action", "taskId", "callerTaskId", "effectKeyStep", "amountMinor", "amountCurrency",
445
+ "grantTtlMs", "correlationId", "parentIntentId", "contractId", "model", "provenanceStatus",
446
+ "externalRefs", "payload",
447
+ ]);
448
+
449
+ const ACCESS_KEYS = new Set([
450
+ "capability", "actions", "resourceId", "surfaceResourceId", "teamId", "dataClass", "tier",
451
+ "budgetMinor", "budgetCurrency", "ttlSeconds", "taskId", "justification", "duration", "reviewDays",
452
+ "windowStart", "windowEnd", "windowTimezone",
453
+ ]);
454
+
455
+ const OBSERVE_KEYS = new Set(["intentId", "effectId", "outcome", "observedAt", "note"]);
456
+
457
+ export class Client {
458
+ constructor({ endpoint, token, retries, timeoutMs, fetchImpl } = {}) {
459
+ const base = String(endpoint ?? process.env[ENDPOINT_ENV] ?? "").trim().replace(/\/+$/, "");
460
+ if (!base) {
461
+ throw new AaesError(`aaes: an endpoint is required; pass endpoint or set ${ENDPOINT_ENV} to the daemon base URL`);
462
+ }
463
+ let parsed;
464
+ try {
465
+ parsed = new URL(base);
466
+ } catch {
467
+ throw new AaesError("aaes: endpoint is not a URL");
468
+ }
469
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
470
+ throw new AaesError("aaes: endpoint must use http or https");
471
+ }
472
+ const host = parsed.hostname.toLowerCase();
473
+ if (parsed.protocol === "http:" && host !== "localhost" && host !== "[::1]" &&
474
+ !/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) {
475
+ throw new AaesError("aaes: endpoint requires HTTPS or explicit loopback HTTP");
476
+ }
477
+ if (parsed.username || parsed.password) {
478
+ throw new AaesError(
479
+ "aaes: endpoint carries credentials in its authority; the bearer token is configuration, not part of the URL",
480
+ );
481
+ }
482
+ if (base.includes("?") || base.includes("#")) {
483
+ throw new AaesError("aaes: endpoint must not carry a query or fragment");
484
+ }
485
+ const configured = token ?? process.env[TOKEN_ENV] ?? "";
486
+ if (typeof configured !== "string") {
487
+ throw new AaesError(`aaes: ${TOKEN_ENV} must be an ASCII bearer token`);
488
+ }
489
+ const secret = configured.trim();
490
+ if ([...secret].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) > 126)) {
491
+ throw new AaesError(`aaes: ${TOKEN_ENV} must not contain control or non-ASCII characters`);
492
+ }
493
+ if (!secret) throw new AaesError(`aaes: a bearer token is required; pass token or set ${TOKEN_ENV}`);
494
+ const doFetch = fetchImpl ?? globalThis.fetch;
495
+ if (typeof doFetch !== "function") {
496
+ throw new AaesError("aaes: this runtime has no fetch; pass fetchImpl or run on Node 18+");
497
+ }
498
+ this._endpoint = base;
499
+ this._token = secret;
500
+ this._retries = retries ?? new RetryPolicy();
501
+ this._timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
502
+ this._fetch = doFetch;
503
+ this._entitlement = null;
504
+ this._entitlementFlight = null;
505
+ this._catalog = null;
506
+ this._catalogSeenMs = 0;
507
+ }
508
+
509
+ /** The ten-minute path: AAES_ENDPOINT and AAES_TOKEN are the whole
510
+ * configuration. */
511
+ static fromEnv(options = {}) {
512
+ return new Client(options);
513
+ }
514
+
515
+ /** Forget the cached entitled slice and the published catalogue snapshot. The
516
+ * cache can never widen authority - the daemon decides every call - but an
517
+ * operator who just changed a scope does not have to restart the process. */
518
+ refresh() {
519
+ this._entitlement = null;
520
+ this._entitlementFlight = null;
521
+ this._catalog = null;
522
+ this._catalogSeenMs = 0;
523
+ }
524
+
525
+ /** The caller's entitled slice, read once per client until refresh(). It
526
+ * supplies the tenant and actor a call is attributed to and the registered
527
+ * tier it is sent with: both are the daemon's answers. */
528
+ async entitlement() {
529
+ if (this._entitlement !== null) return this._entitlement;
530
+ if (this._entitlementFlight !== null) return this._entitlementFlight;
531
+ const flight = this.capabilities().then((entitled) => {
532
+ if (!entitled.tenantId.trim() || !entitled.actorId.trim()) {
533
+ throw new AaesError(
534
+ "aaes: the daemon's entitled slice names no tenant_id or actor_id, so a call cannot be attributed to a caller",
535
+ );
536
+ }
537
+ // Refresh detaches the previous flight so it cannot repopulate the cache.
538
+ if (this._entitlementFlight === flight) this._entitlement = entitled;
539
+ return entitled;
540
+ }).finally(() => {
541
+ if (this._entitlementFlight === flight) this._entitlementFlight = null;
542
+ });
543
+ this._entitlementFlight = flight;
544
+ return flight;
545
+ }
546
+
547
+ /** GET /v1/capabilities: what this caller may USE. */
548
+ async capabilities() {
549
+ const { status, body } = await this._request("GET", PATH_CAPABILITIES, null, { identified: true });
550
+ if (status !== 200) throw this._statusError("GET " + PATH_CAPABILITIES, status, body);
551
+ return {
552
+ tenantId: body.tenant_id ?? "",
553
+ actorId: body.actor_id ?? "",
554
+ capabilities: (body.capabilities ?? []).map(capabilityFrom),
555
+ find(spelling) {
556
+ return findCapability(this.capabilities, spelling);
557
+ },
558
+ };
559
+ }
560
+
561
+ /** GET /v1/catalogue: what this caller may ASK FOR. A separate question from
562
+ * capabilities(), and the daemon keeps the two apart on purpose. A success is
563
+ * remembered as the published snapshot refusal advice reads until it ages out
564
+ * or refresh() forgets it. */
565
+ async catalog() {
566
+ const { status, body } = await this._request("GET", PATH_CATALOGUE, null, { identified: true });
567
+ if (status !== 200) throw this._statusError("GET " + PATH_CATALOGUE, status, body);
568
+ const catalog = {
569
+ tenantId: body.tenant_id ?? "",
570
+ actorId: body.actor_id ?? "",
571
+ requestable: (body.requestable ?? []).map(catalogueEntryFrom),
572
+ find(spelling) {
573
+ return findRequestable(this.requestable, spelling);
574
+ },
575
+ };
576
+ this._catalog = catalog;
577
+ this._catalogSeenMs = Date.now();
578
+ return catalog;
579
+ }
580
+
581
+ /** GET /healthz and GET /readyz. A not-ready 503 from readyz is an answer, not
582
+ * a failure, and is returned with its reason. */
583
+ async health() {
584
+ const live = await this._request("GET", PATH_HEALTHZ, null, { identified: true, answer503: true });
585
+ if (live.status !== 200) throw this._statusError("GET " + PATH_HEALTHZ, live.status, live.body);
586
+ const ready = await this._request("GET", PATH_READYZ, null, { identified: true, answer503: true });
587
+ if (ready.status !== 200 && ready.status !== 503) {
588
+ throw this._statusError("GET " + PATH_READYZ, ready.status, ready.body);
589
+ }
590
+ return {
591
+ healthz: live.body,
592
+ readyz: ready.body,
593
+ ready: ready.status === 200 && ready.body.status === "ready",
594
+ reason: ready.body.reason ?? "",
595
+ };
596
+ }
597
+
598
+ /** POST /v1/action: decide one action. It executes nothing; brokered()
599
+ * executes through AAES. A refusal is returned in decision.refusal. */
600
+ async action(action) {
601
+ const { wire, info } = await this._actionBody(action);
602
+ const identified = Boolean(info.effectKeyStep);
603
+ const { status, body } = await this._request("POST", PATH_ACTION, wire, { identified });
604
+ return await this._decision(status, body, info, identified);
605
+ }
606
+
607
+ /** POST /v1/action/resume: complete a deferred decision with the same body
608
+ * and effect key. A 404 means no deferral suspends this intent; the refusal
609
+ * carries the daemon's reason verbatim when it stated one. */
610
+ async resume(action) {
611
+ const { wire, info } = await this._actionBody(action);
612
+ const identified = Boolean(info.effectKeyStep);
613
+ const { status, body, rawText } = await this._request("POST", PATH_RESUME, wire, { identified, undecoded: UNDECODED_404 });
614
+ if (status === 404) {
615
+ const reason = noDeferralReason(rawText);
616
+ return new Decision({ reason, refusal: new Refusal(404, reason) });
617
+ }
618
+ return await this._decision(status, body, info, identified);
619
+ }
620
+
621
+ /** POST /v1/brokered: the same decision path, then execution through the
622
+ * daemon's connector. You still name only a capability and an action. */
623
+ async brokered(action) {
624
+ const { wire, info } = await this._actionBody(action);
625
+ const identified = Boolean(info.effectKeyStep);
626
+ const { status, body } = await this._request("POST", PATH_BROKERED, wire, { identified });
627
+ const decision = await this._decision(status, body, info, identified);
628
+ // Connector observations can accompany a refusal after sealing or a
629
+ // duplicate effect; they serve reconciliation, and the decision stays the
630
+ // authority for what happened.
631
+ return { decision, outcome: brokeredOutcome(body, decision, PATH_BROKERED) };
632
+ }
633
+
634
+ /** POST /v1/brokered/resume: complete a deferred brokered action with the
635
+ * same body and effect key. The daemon revalidates and executes through the
636
+ * connector, or refuses; a grant without a confirmed execution is never
637
+ * reported as success. On allow, outcome says what the connector observed.
638
+ * A 404 means no deferral suspends this intent; the refusal carries the
639
+ * daemon's reason verbatim when it stated one. */
640
+ async resumeBrokered(action) {
641
+ const { wire, info } = await this._actionBody(action);
642
+ const identified = Boolean(info.effectKeyStep);
643
+ const { status, body, rawText } = await this._request(
644
+ "POST",
645
+ PATH_BROKERED_RESUME,
646
+ wire,
647
+ { identified, undecoded: UNDECODED_404 },
648
+ );
649
+ if (status === 404) {
650
+ const reason = noDeferralReason(rawText);
651
+ return { decision: new Decision({ reason, refusal: new Refusal(404, reason) }), outcome: {} };
652
+ }
653
+ const decision = await this._decision(status, body, info, identified);
654
+ // Connector observations can accompany a refusal after sealing or a
655
+ // duplicate effect; they serve reconciliation, and the decision stays the
656
+ // authority for what happened.
657
+ return { decision, outcome: brokeredOutcome(body, decision, PATH_BROKERED_RESUME) };
658
+ }
659
+
660
+ /** POST /v1/grants/present: bind a stored grant to the authenticated caller.
661
+ * It does not execute. A 403 means the presenter is not the grant's actor; a
662
+ * 404 means the grant is unknown, expired or revoked. */
663
+ async presentGrant(grantId) {
664
+ grantId = String(grantId ?? "").trim();
665
+ if (!grantId) throw new AaesError("aaes: present names a grant_id; none was given");
666
+ const { status, body } = await this._request(
667
+ "POST",
668
+ PATH_GRANT_PRESENT,
669
+ { grant_id: grantId },
670
+ { identified: false },
671
+ );
672
+ if (status !== 200) throw this._statusError("POST " + PATH_GRANT_PRESENT, status, body);
673
+ return grantPresentFrom(body);
674
+ }
675
+
676
+ /** POST /v1/federate: run the full decision and return a FEDERATED GRANT -- the
677
+ * material (or the instructions) YOU use to call the application itself, so no
678
+ * content transits AAES.
679
+ *
680
+ * The three rules this client enforces:
681
+ *
682
+ * 1. NEVER a URL or a credential from the caller. This call names one
683
+ * capability, one action and one task; any extra key -- url, endpoint,
684
+ * destination, method, header, credential, token -- is refused before a
685
+ * request is sent.
686
+ * 2. NEVER log the material. FederatedMaterial and FederatedGrant render with
687
+ * the credential REDACTED, toJSON included.
688
+ * 3. NEVER retry a mint without an effect key. effectKeyStep is required, and a
689
+ * 503 is repeated only because the daemon can refuse the duplicate under the
690
+ * same key with 409 instead of minting twice.
691
+ */
692
+ async federate(request = {}) {
693
+ refuseUnknownKeys(FEDERATE_KEYS, request, "federated call");
694
+ const capability = String(request.capability ?? "").trim();
695
+ if (!capability) throw new AaesError("aaes: a federated call names a capability; none was given");
696
+ const taskId = String(request.taskId ?? "").trim();
697
+ if (!taskId) {
698
+ throw new AaesError("aaes: a federated call names the task it is bounded by; none was given");
699
+ }
700
+ const effectKeyStep = String(request.effectKeyStep ?? "").trim();
701
+ if (!effectKeyStep) {
702
+ throw new AaesError(
703
+ "aaes: a federated call presents an effect key step. A mint is an irreversible act that creates authority, and a retry without a step name could not be told from a second mint",
704
+ );
705
+ }
706
+ // A non-integer amount is refused HERE: the wire carries integer minor
707
+ // units of one ISO 4217 currency, and a float cannot state cents honestly.
708
+ // AAES never converts.
709
+ const amountMinor = requireMinorAmount(request.amountMinor, "amountMinor");
710
+ const amountCurrency = String(request.amountCurrency ?? "").trim().toUpperCase();
711
+ const grantTtlMs = request.grantTtlMs ?? 0;
712
+ if (grantTtlMs < 0) throw new AaesError("aaes: a federated call cannot ask for a negative grantTtlMs");
713
+ // Only the keys a caller actually set are sent, exactly as the Go and
714
+ // Python clients do: an omitted field and a zero field are different facts on
715
+ // the wire, and the daemon's own defaults are the ones that should apply.
716
+ const wire = { capability, task_id: taskId, effect_key_step: effectKeyStep };
717
+ const action = String(request.action ?? "").trim();
718
+ if (action) wire.action = action;
719
+ // amount_minor is ALWAYS on the wire, including the zero value. Omitting
720
+ // it when unset decodes to the same zero on the daemon, but it hides the
721
+ // one fact an operator scanning traffic needs to see -- that a caller asked
722
+ // for a grant without pricing the action -- and it invites client authors
723
+ // to believe an unset amount is somebody else's default. It is not: the
724
+ // reservation for an unpriced capability without a registered ceiling is
725
+ // exactly zero.
726
+ wire.amount_minor = amountMinor;
727
+ if (amountCurrency) wire.amount_currency = amountCurrency;
728
+ if (grantTtlMs) wire.grant_ttl_ms = grantTtlMs;
729
+ if (request.rotate) wire.rotate = true;
730
+ const correlationId = String(request.correlationId ?? "").trim();
731
+ if (correlationId) wire.correlation_id = correlationId;
732
+ const info = {
733
+ capability,
734
+ action: String(request.action ?? "").trim(),
735
+ effectKeyStep,
736
+ taskId,
737
+ };
738
+ const { status, body } = await this._request("POST", PATH_FEDERATE, wire, { identified: true });
739
+ if (status !== 200 || body.allowed !== true || !body.grant) {
740
+ const reason = reasonOf(body);
741
+ return {
742
+ ok: false,
743
+ allowed: false,
744
+ reason,
745
+ grant: null,
746
+ refusal: await this._refuse(status, reason, info, true),
747
+ };
748
+ }
749
+ const grant = federatedGrantFrom(body.grant, body.intent_id);
750
+ if (grant.record.mode !== "federated" || grant.record.payloadSeen) {
751
+ throw new AaesError(
752
+ "aaes: the daemon returned a grant whose record says mode=" + JSON.stringify(grant.record.mode) +
753
+ " payload_seen=" + grant.record.payloadSeen +
754
+ "; a federated grant's record must say mode=federated and payload_seen=false",
755
+ );
756
+ }
757
+ if (!grant.federationId || !grant.intentId) {
758
+ throw new AaesError("aaes: the daemon returned a grant that names no federation id or intent id");
759
+ }
760
+ if (String(grant.record.intentId ?? "").trim() !== String(grant.intentId ?? "").trim()) {
761
+ throw new AaesError("aaes: the daemon returned a grant whose sealed record does not name this intent");
762
+ }
763
+ if (!String(grant.record.markerReceiptHash ?? "").trim()) {
764
+ throw new AaesError("aaes: the daemon returned a federated grant with no marker receipt hash");
765
+ }
766
+ if (!federatedCapabilityMatches(capability, grant) || grant.taskId !== taskId) {
767
+ throw new AaesError("aaes: the daemon returned a grant whose capability or task is not the federated action requested");
768
+ }
769
+ return { ok: true, allowed: true, reason: "", grant, refusal: null };
770
+ }
771
+
772
+ /** POST /v1/access-requests: ask an authorized approver for a time-bound permission. There is
773
+ * no idempotency key on this route, so this client never retries it; a 409
774
+ * means the actor already holds the permission, which is the correct answer
775
+ * rather than a failure. */
776
+ async requestAccess(ask = {}) {
777
+ refuseUnknownKeys(ACCESS_KEYS, ask, "access request");
778
+ const capability = String(ask.capability ?? "").trim();
779
+ if (!capability) throw new AaesError("aaes: an access request names a capability; none was given");
780
+ const actions = (ask.actions ?? []).map((a) => String(a).trim()).filter(Boolean);
781
+ if (actions.length === 0) {
782
+ // An ask with no actions is not "every action": the daemon refuses an
783
+ // entitlement that names no action, because nobody can review a permission
784
+ // that was never stated. The client says so here rather than letting the
785
+ // caller learn it from a 400.
786
+ throw new AaesError(
787
+ "aaes: an access request names its actions; an entitlement with no action authorises nothing and no person can review it",
788
+ );
789
+ }
790
+ const wire = {
791
+ capability,
792
+ actions,
793
+ resource_id: String(ask.resourceId ?? "").trim(),
794
+ data_class: String(ask.dataClass ?? "").trim(),
795
+ tier: String(ask.tier ?? "").trim(),
796
+ budget_minor: requireMinorAmount(ask.budgetMinor, "budgetMinor"),
797
+ budget_currency: String(ask.budgetCurrency ?? "").trim().toUpperCase(),
798
+ ttl_seconds: ask.ttlSeconds ?? 0,
799
+ task_id: String(ask.taskId ?? "").trim(),
800
+ justification: String(ask.justification ?? "").trim(),
801
+ };
802
+ // The ADR-010 scoping and duration fields. Each is added only when the
803
+ // caller supplied it, exactly like the optional fields above.
804
+ const surfaceResourceId = String(ask.surfaceResourceId ?? "").trim();
805
+ if (surfaceResourceId) wire.surface_resource_id = surfaceResourceId;
806
+ const teamId = String(ask.teamId ?? "").trim();
807
+ if (teamId) wire.team_id = teamId;
808
+ const duration = String(ask.duration ?? "").trim();
809
+ if (duration) wire.duration = duration;
810
+ if (ask.reviewDays) wire.review_days = ask.reviewDays;
811
+ const windowStart = String(ask.windowStart ?? "").trim();
812
+ if (windowStart) wire.window_start = windowStart;
813
+ const windowEnd = String(ask.windowEnd ?? "").trim();
814
+ if (windowEnd) wire.window_end = windowEnd;
815
+ const windowTimezone = String(ask.windowTimezone ?? "").trim();
816
+ if (windowTimezone) wire.window_timezone = windowTimezone;
817
+ if (!wire.tier) {
818
+ try {
819
+ const entry = (await this.catalog()).find(capability);
820
+ if (entry) wire.tier = entry.riskTier;
821
+ } catch {
822
+ // The catalog is a convenience here; the ask is still the caller's.
823
+ }
824
+ }
825
+ const info = { capability, ask: true };
826
+ const { status, body } = await this._request("POST", PATH_ACCESS_REQUESTS, wire, { identified: false });
827
+ if (status === 200 || status === 201) {
828
+ return {
829
+ requestId: body.request_id ?? "",
830
+ state: body.state ?? "",
831
+ needed: body.needed ?? {},
832
+ alreadyHeld: false,
833
+ refusal: null,
834
+ };
835
+ }
836
+ return {
837
+ requestId: "",
838
+ state: "",
839
+ needed: {},
840
+ alreadyHeld: status === 409,
841
+ refusal: await this._refuse(status, reasonOf(body), info, false),
842
+ };
843
+ }
844
+
845
+ /** GET /v1/access-requests/{id}. */
846
+ async accessRequest(requestId) {
847
+ const id = String(requestId ?? "").trim();
848
+ if (!id) throw new AaesError("aaes: an access request id is required");
849
+ const path = PATH_ACCESS_REQUESTS + "/" + encodeURIComponent(id);
850
+ const { status, body } = await this._request("GET", path, null, { identified: true });
851
+ if (status !== 200) throw this._statusError("GET " + path, status, body);
852
+ return accessViewFrom(body);
853
+ }
854
+
855
+ /** POST /v1/observe: report the effect of an intent the daemon authorized
856
+ * under pass_through custody. The answer is the daemon's observational
857
+ * receipt: attributed, not trusted, and never counted toward coverage. */
858
+ async observe(report = {}) {
859
+ refuseUnknownKeys(OBSERVE_KEYS, report, "observation report");
860
+ const intentId = String(report.intentId ?? "").trim();
861
+ if (!intentId) throw new AaesError("aaes: a report names the intent it is about; no intentId was given");
862
+ const wire = { intent_id: intentId };
863
+ if (report.effectId) wire.effect_id = String(report.effectId).trim();
864
+ if (report.outcome) wire.outcome = String(report.outcome).trim();
865
+ if (report.observedAt) wire.observed_at = report.observedAt;
866
+ if (report.note) wire.note = String(report.note).trim();
867
+ const { status, body } = await this._request("POST", PATH_OBSERVE, wire, { identified: true });
868
+ if (status !== 200) {
869
+ return {
870
+ intentId,
871
+ receipt: null,
872
+ label: "",
873
+ enforcement: "",
874
+ countsTowardCoverage: false,
875
+ refusal: await this._refuse(status, reasonOf(body), { intentId }, true),
876
+ };
877
+ }
878
+ return {
879
+ intentId: body.intent_id ?? "",
880
+ receipt: body.receipt ? receiptFrom(body.receipt) : null,
881
+ label: body.label ?? "",
882
+ enforcement: body.enforcement ?? "",
883
+ countsTowardCoverage: Boolean(body.counts_toward_coverage),
884
+ refusal: null,
885
+ };
886
+ }
887
+
888
+ // -- internals ------------------------------------------------------------
889
+
890
+ // Read APIs retain snake_case wire fields. Filters never change the bearer identity.
891
+ async _readRecord(path) {
892
+ const { status, body } = await this._request("GET", path, null, { identified: true });
893
+ if (status !== 200) throw this._statusError("GET " + path, status, body);
894
+ return body;
895
+ }
896
+
897
+ async listTask(filters = {}) {
898
+ return this._readRecord("/v1/task" + readQuery(filters, {"limit": "limit", "cursor": "cursor", "since": "since", "until": "until", "state": "state", "sponsor": "sponsor", "assignee": "assignee", "overdue": "overdue"}));
899
+ }
900
+
901
+ async listApprovals(filters = {}) {
902
+ return this._readRecord("/v1/approvals" + readQuery(filters, {"limit": "limit", "cursor": "cursor", "since": "since", "until": "until", "intentId": "intent_id", "taskId": "task_id", "state": "state"}));
903
+ }
904
+
905
+ async listEntitlements(filters = {}) {
906
+ return this._readRecord("/v1/entitlements" + readQuery(filters, {"limit": "limit", "cursor": "cursor", "actorId": "actor_id", "capability": "capability", "state": "state"}));
907
+ }
908
+
909
+ async listReceipts(filters = {}) {
910
+ return this._readRecord("/v1/receipts" + readQuery(filters, {"limit": "limit", "cursor": "cursor", "since": "since", "until": "until", "intentId": "intent_id", "taskId": "task_id", "effectClass": "class", "custody": "custody"}));
911
+ }
912
+
913
+ async task(id) { return this._readRecord("/v1/task/" + recordId(id)); }
914
+ async approval(id) { return this._readRecord("/v1/approvals/" + recordId(id)); }
915
+ async receipt(id) { return this._readRecord("/v1/receipts/" + recordId(id)); }
916
+
917
+ async managerCards() { return this._readRecord("/v1/manager-cards"); }
918
+
919
+ async decideManagerCard(decision) {
920
+ refuseUnknownKeys(new Set(["cardId", "approvalId", "payloadHash", "policyId", "grant", "reason"]), decision, "manager card decision");
921
+ for (const key of ["cardId", "approvalId", "payloadHash"]) {
922
+ if (typeof decision[key] !== "string" || !decision[key].trim()) throw new AaesError("aaes: " + key + " is required");
923
+ }
924
+ if (typeof decision.grant !== "boolean") throw new AaesError("aaes: grant must be an explicit boolean");
925
+ const wire = { card_id: decision.cardId, approval_id: decision.approvalId, payload_hash: decision.payloadHash, grant: decision.grant };
926
+ if (decision.policyId !== undefined) wire.policy_id = decision.policyId;
927
+ if (decision.reason !== undefined) wire.reason = decision.reason;
928
+ const { status, body } = await this._request("POST", "/v1/manager-cards/decide", wire, { identified: false });
929
+ if (status !== 200) throw this._statusError("manager card decision", status, body);
930
+ return body;
931
+ }
932
+
933
+ async _actionBody(action = {}) {
934
+ refuseUnknownKeys(ACTION_KEYS, action, "action");
935
+ const capability = String(action.capability ?? "").trim();
936
+ if (!capability) throw new AaesError("aaes: an action names a capability; none was given");
937
+ const amountMinor = requireMinorAmount(action.amountMinor, "amountMinor");
938
+ const payload = guardPayload(action.payload);
939
+ const entitled = await this.entitlement();
940
+ const info = {
941
+ capability,
942
+ action: String(action.action ?? "").trim(),
943
+ effectKeyStep: String(action.effectKeyStep ?? "").trim(),
944
+ taskId: String(action.taskId ?? "").trim(),
945
+ ask: false,
946
+ };
947
+ const wire = prune({
948
+ tenant_id: entitled.tenantId,
949
+ actor_id: entitled.actorId,
950
+ caller_task_id: String(action.callerTaskId ?? "").trim(),
951
+ task_id: info.taskId,
952
+ effect_key_step: info.effectKeyStep,
953
+ capability,
954
+ tier: "",
955
+ action: info.action,
956
+ correlation_id: String(action.correlationId ?? "").trim(),
957
+ parent_intent_id: String(action.parentIntentId ?? "").trim(),
958
+ contract_id: String(action.contractId ?? "").trim(),
959
+ model: String(action.model ?? "").trim(),
960
+ provenance_status: String(action.provenanceStatus ?? "").trim() || undefined,
961
+ external_refs: Array.isArray(action.externalRefs) && action.externalRefs.length
962
+ ? action.externalRefs
963
+ : undefined,
964
+ amount_minor: amountMinor,
965
+ amount_currency: String(action.amountCurrency ?? "").trim().toUpperCase() || undefined,
966
+ grant_ttl_ms: action.grantTtlMs,
967
+ payload,
968
+ });
969
+ const entry = entitled.find(capability);
970
+ if (!entry || typeof entry.riskTier !== "string" || !entry.riskTier.trim()) {
971
+ throw new AaesError(
972
+ `aaes: no published risk tier for capability ${JSON.stringify(capability)}; refresh capabilities or request access before an action`,
973
+ );
974
+ }
975
+ // The tier the daemon published for this caller. A caller cannot raise its
976
+ // own tier, and the daemon refuses one below the registration.
977
+ wire.tier = entry.riskTier;
978
+ if (!wire.task_id) wire.task_id = entry.taskId;
979
+ if (!wire.caller_task_id) wire.caller_task_id = entry.callerTaskId;
980
+ return { wire: prune(wire), info };
981
+ }
982
+
983
+ async _decision(status, body, info, identified) {
984
+ const decision = new Decision({
985
+ allowed: body.allowed === true && status === 200,
986
+ deferred: status === 202 && body.deferred === true,
987
+ reason: String(body.reason ?? "").trim(),
988
+ intentId: String(body.intent_id ?? "").trim(),
989
+ grant: body.grant ? grantFrom(body.grant) : null,
990
+ receipt: body.receipt ? receiptFrom(body.receipt) : null,
991
+ latencyAddedMs: finiteNumberFrom(body.latency_added_ms, "latency_added_ms"),
992
+ });
993
+ if (!decision.intentId && decision.receipt) decision.intentId = decision.receipt.intentId;
994
+ if (decision.ok || decision.deferred) return decision;
995
+ if (!decision.reason) decision.reason = reasonOf(body);
996
+ decision.refusal = await this._refuse(status, decision.reason, info, identified);
997
+ return decision;
998
+ }
999
+
1000
+ async _refuse(status, reason, info, identified) {
1001
+ const text = String(reason ?? "").trim() || `the daemon answered HTTP ${status} and stated no reason`;
1002
+ const refusal = new Refusal(status, text);
1003
+ if (status === 503) {
1004
+ refusal.retryable = Boolean(identified && info.effectKeyStep && !info.ask);
1005
+ refusal.advice.push(this._retryAdvice(info, identified));
1006
+ return refusal;
1007
+ }
1008
+ if (status === 409) {
1009
+ if (info.ask) {
1010
+ refusal.advice.push(
1011
+ "nothing to change: the daemon refuses to raise a request for a permission the actor already holds. Read what you hold (capabilities) or the request that issued it instead of re-asking; no approver was asked and no approval was consumed",
1012
+ );
1013
+ } else if (info.effectKeyStep) {
1014
+ refusal.advice.push(
1015
+ `this effect key is already committed for this task, so the retry was refused rather than repeated. Do not re-issue the first attempt: it either happened (the receipt is the evidence) or it did not (the record says so). Only if this really is a NEW effect, present a new effectKeyStep instead of ${JSON.stringify(info.effectKeyStep)}`,
1016
+ );
1017
+ } else {
1018
+ refusal.advice.push(
1019
+ "the daemon refused this as a duplicate of an effect already committed for this task. Do not re-issue it; read the record for the task instead. If it really is a new effect, name it with a distinct effectKeyStep",
1020
+ );
1021
+ }
1022
+ return refusal;
1023
+ }
1024
+ if (status === 401) {
1025
+ refusal.advice.push(
1026
+ `check ${TOKEN_ENV}: the daemon refused the bearer token, and no decision was taken. The token is sent only in the Authorization header, so a token that tasks elsewhere and not here is a token for another tenant or a revoked key`,
1027
+ );
1028
+ return refusal;
1029
+ }
1030
+ if (status === 400) {
1031
+ refusal.advice.push(
1032
+ "the reason names the field the daemon refused; fix that field and send the call again. This client sends only documented fields, so a field named in a payload refusal is the caller's own",
1033
+ );
1034
+ return refusal;
1035
+ }
1036
+ if (status === 404 && info.intentId) {
1037
+ refusal.advice.push(
1038
+ `check the intent id ${JSON.stringify(info.intentId)}: the daemon looks it up scoped to the caller's tenant, so an id raised elsewhere reads as not found`,
1039
+ );
1040
+ return refusal;
1041
+ }
1042
+ refusal.advice.push(...(await this._publishedAdvice(info, text)));
1043
+ if (refusal.advice.length === 0) {
1044
+ refusal.advice.push(
1045
+ "the daemon stated no field this client can change; read the reason above and change the field it names",
1046
+ );
1047
+ }
1048
+ return refusal;
1049
+ }
1050
+
1051
+ _retryAdvice(info, identified) {
1052
+ if (info.ask) {
1053
+ return "the daemon could not decide safely and raised nothing. An access request carries no effect key on the daemon side, so this client NEVER retries it: re-ask deliberately, after reading any request you already raised (accessRequest).";
1054
+ }
1055
+ if (identified) {
1056
+ return `nothing was minted. This call presents effectKeyStep ${JSON.stringify(info.effectKeyStep)}, so repeating it is safe: the daemon commits the effect key in the same journal write that authorises the action, and a duplicate attempt under it is refused with 409 rather than repeated. This client repeats it only when you opt in with RetryPolicy.default()`;
1057
+ }
1058
+ return "the daemon could not complete this call safely; its effect may be uncertain. A retry is safe only under an effect key, and this call named none, so this client does not repeat it. Add effectKeyStep and opt in with RetryPolicy.default() if you want a 503 repeated";
1059
+ }
1060
+
1061
+ async _publishedAdvice(info, reason) {
1062
+ const out = [];
1063
+ const lowered = String(reason ?? "").toLowerCase();
1064
+ if (lowered.includes("approval") || lowered.includes("human") || lowered.includes("person")) {
1065
+ out.push(
1066
+ "an operator approval gate is part of this decision: the daemon refuses until an unexpired approval raised against THIS intent exists, and raises a pending one for it. The approval is decided in the operator console; nothing the caller does changes the decision",
1067
+ );
1068
+ }
1069
+ const capability = String(info.capability ?? "").trim();
1070
+ if (!capability) return out;
1071
+ // Advice is observational and must never add network latency to a refusal,
1072
+ // so only snapshots an earlier read already published are consulted: the
1073
+ // catalogue snapshot expires after CATALOGUE_SNAPSHOT_FRESH_MS and
1074
+ // refresh() clears both it and the entitled slice. With no snapshot the
1075
+ // advice is the same one a failed read produces.
1076
+ const fresh = this._catalog !== null && Date.now() - this._catalogSeenMs <= CATALOGUE_SNAPSHOT_FRESH_MS;
1077
+ const catalog = fresh ? this._catalog : null;
1078
+ let catalogueError = null;
1079
+ if (catalog === null) catalogueError = new AaesError("no recent published catalogue snapshot");
1080
+ const entry = catalog === null ? null : catalog.find(capability);
1081
+ if (entry) {
1082
+ out.push(
1083
+ `request it: requestAccess({ capability: ${JSON.stringify(capability)}, actions: ${JSON.stringify(entry.actions)}, tier: ${JSON.stringify(entry.riskTier)}, resourceId: ${JSON.stringify(entry.resourceId)}, dataClass: ${JSON.stringify(entry.dataClass)}, taskId: ..., justification: ... }). The daemon publishes tier ${entry.riskTier}, resource ${JSON.stringify(entry.resourceId)} and data class ${JSON.stringify(entry.dataClass)} for this capability, and a request raises one operator approval whose entitlement is time-bound`,
1084
+ );
1085
+ return out;
1086
+ }
1087
+ const entitled = this._entitlement;
1088
+ let entitledError = null;
1089
+ if (entitled === null) entitledError = new AaesError("no published entitlement snapshot");
1090
+ const entitledEntry = entitled === null ? null : entitled.find(capability);
1091
+ if (entitledEntry) {
1092
+ const action = String(info.action ?? "").trim();
1093
+ if (entitledEntry.actions.length > 0 && action && !entitledEntry.actions.includes(action)) {
1094
+ out.push(
1095
+ `the daemon entitles this capability with actions ${JSON.stringify(entitledEntry.actions)}, and this call asked for ${JSON.stringify(action)}: use one of the published actions, or ask for the action you need (catalog then requestAccess)`,
1096
+ );
1097
+ } else if (entitledEntry.riskTier) {
1098
+ out.push(
1099
+ `the daemon entitles this capability at tier ${entitledEntry.riskTier} with actions ${JSON.stringify(entitledEntry.actions)}; the refusal above names what else the decision required (tier, resource, data class, task or budget)`,
1100
+ );
1101
+ }
1102
+ return out;
1103
+ }
1104
+ if (catalogueError === null && entitledError === null) {
1105
+ out.push(
1106
+ `the daemon publishes capability ${JSON.stringify(capability)} neither as an entitlement nor on the requestable catalogue, so no client change satisfies this: an operator has to register it (aaesctl capability) or opt it into the catalogue (aaesctl access catalogue) first. catalog() lists everything that may be asked for`,
1107
+ );
1108
+ } else if (catalogueError !== null) {
1109
+ out.push(
1110
+ `the requestable catalogue could not be read (${catalogueError.message}), so this client cannot name the smallest change; the daemon's reason above is the whole answer`,
1111
+ );
1112
+ }
1113
+ return out;
1114
+ }
1115
+
1116
+ async _request(method, path, body, { identified, answer503 = false, undecoded = null }) {
1117
+ const payload = body === null ? undefined : JSON.stringify(body);
1118
+ const attempts = this._retries.attempts(identified, answer503);
1119
+ let answer;
1120
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
1121
+ if (attempt > 1) await sleep(this._retries.delay(attempt - 1));
1122
+ answer = await this._once(method, path, payload, undecoded);
1123
+ // A transport or decoding failure does not establish that nothing was
1124
+ // minted. Only the daemon's explicit 503 answer permits repetition.
1125
+ if (answer.status !== 503) return answer;
1126
+ }
1127
+ // The 503 that ends the call is decoded here and only into an object: the
1128
+ // daemon's JSON reason reaches the refusal, while a proxy's HTML
1129
+ // maintenance page reads as no stated reason under the same status rather
1130
+ // than a decode error that would claim no answer arrived.
1131
+ return answer === undefined ? answer : { ...answer, body: decodeObjectBody(answer.rawText) };
1132
+ }
1133
+
1134
+ async _once(method, path, payload, undecoded = null) {
1135
+ let response;
1136
+ try {
1137
+ response = await this._fetch(this._endpoint + path, {
1138
+ method,
1139
+ headers: {
1140
+ Accept: "application/json",
1141
+ ...(payload === undefined ? {} : { "Content-Type": "application/json" }),
1142
+ // The token is put in a header and nowhere else: it never enters a URL,
1143
+ // a query, an error string or a log line.
1144
+ Authorization: "Bearer " + this._token,
1145
+ },
1146
+ body: payload,
1147
+ redirect: "manual",
1148
+ signal: AbortSignal.timeout(this._timeoutMs),
1149
+ });
1150
+ } catch (err) {
1151
+ throw new AaesError(`aaes: ${method} ${path}: ${err.message}`);
1152
+ }
1153
+ if (response.status >= 300 && response.status < 400) {
1154
+ await cancelBody(response);
1155
+ throw new AaesError(
1156
+ `aaes: refusing to follow a redirect to ${response.headers.get("location") ?? "another host"}: the daemon must answer at the configured endpoint`,
1157
+ );
1158
+ }
1159
+ let raw;
1160
+ try {
1161
+ raw = await readBoundedResponse(response, method, path);
1162
+ } catch (err) {
1163
+ await cancelBody(response);
1164
+ // A body read fails the same way a failed fetch does: the read's own
1165
+ // refusals are already typed, and anything the stream or the abort
1166
+ // signal throws keeps its original error as the cause.
1167
+ if (err instanceof AaesError) throw err;
1168
+ throw new AaesError(`aaes: ${method} ${path}: ${err.message}`, { cause: err });
1169
+ }
1170
+ // A status the caller reads without decoding is returned with its raw
1171
+ // text; the caller parses lazily, so an unparseable body cannot turn the
1172
+ // mapped status into a decode error.
1173
+ if (undecoded !== null && undecoded.has(response.status)) {
1174
+ return { status: response.status, body: {}, rawText: raw };
1175
+ }
1176
+ // A 503 reaches the retry loop as its status alone: a proxy serves its
1177
+ // maintenance page as HTML, and only the 503 that ends the call is
1178
+ // decoded, by _request, once it is surfaced to the caller.
1179
+ if (response.status === 503) {
1180
+ return { status: response.status, body: {}, rawText: raw };
1181
+ }
1182
+ let body = {};
1183
+ if (raw.trim()) {
1184
+ try {
1185
+ body = JSON.parse(raw);
1186
+ } catch {
1187
+ throw new AaesError(`aaes: ${method} ${path} answered HTTP ${response.status} with a body this client could not decode`);
1188
+ }
1189
+ }
1190
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
1191
+ throw new AaesError(`aaes: ${method} ${path} answered HTTP ${response.status} with a JSON ${jsonKind(body)}, not an object`);
1192
+ }
1193
+ return { status: response.status, body };
1194
+ }
1195
+
1196
+ _statusError(what, status, body) {
1197
+ return new AaesError(`aaes: ${what} answered HTTP ${status}: ${reasonOf(body) || "the daemon stated no reason"}`);
1198
+ }
1199
+ }
1200
+
1201
+ // --- helpers ---------------------------------------------------------------
1202
+
1203
+ function brokeredOutcome(body, decision, route) {
1204
+ const present = body !== null && typeof body === "object" && Object.prototype.hasOwnProperty.call(body, "outcome");
1205
+ const outcome = present ? body.outcome : undefined;
1206
+ if (outcome === undefined || outcome === null) {
1207
+ if (!decision.ok) return {};
1208
+ throw new AaesError(
1209
+ `aaes: POST ${route} was allowed and the connector may have executed the effect, but its outcome could not be decoded`,
1210
+ );
1211
+ }
1212
+ const typed = typeof outcome === "object" && !Array.isArray(outcome)
1213
+ && (!("executed" in outcome) || typeof outcome.executed === "boolean")
1214
+ && (!("rail_confirmed" in outcome) || typeof outcome.rail_confirmed === "boolean");
1215
+ if (typed) return outcome;
1216
+ if (decision.ok) {
1217
+ throw new AaesError(
1218
+ `aaes: POST ${route} was allowed and the connector may have executed the effect, but its outcome could not be decoded`,
1219
+ );
1220
+ }
1221
+ throw new AaesError(`aaes: POST ${route} outcome could not be decoded; the connector may have executed the effect`);
1222
+ }
1223
+
1224
+ async function cancelBody(response) {
1225
+ try {
1226
+ await response.body?.cancel();
1227
+ } catch {
1228
+ // The caller's error is the one that matters; this only releases the socket.
1229
+ }
1230
+ }
1231
+
1232
+ async function readBoundedResponse(response, method, path) {
1233
+ if (!response.body) return "";
1234
+ const reader = response.body.getReader();
1235
+ let buffer = new Uint8Array(16 * 1024);
1236
+ let bytes = 0;
1237
+ try {
1238
+ while (true) {
1239
+ const { done, value } = await reader.read();
1240
+ if (done) break;
1241
+ const nextSize = bytes + value.byteLength;
1242
+ if (nextSize > MAX_RESPONSE_BYTES) {
1243
+ await reader.cancel();
1244
+ throw new AaesError(`aaes: ${method} ${path} answered with a body larger than ${MAX_RESPONSE_BYTES} bytes`);
1245
+ }
1246
+ if (nextSize > buffer.length) {
1247
+ const grown = new Uint8Array(Math.min(MAX_RESPONSE_BYTES, Math.max(nextSize, buffer.length * 2)));
1248
+ grown.set(buffer.subarray(0, bytes));
1249
+ buffer = grown;
1250
+ }
1251
+ buffer.set(value, bytes);
1252
+ bytes = nextSize;
1253
+ }
1254
+ return new TextDecoder().decode(buffer.subarray(0, bytes));
1255
+ } finally {
1256
+ reader.releaseLock();
1257
+ }
1258
+ }
1259
+
1260
+ function refuseUnknownKeys(allowed, value, what) {
1261
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1262
+ throw new AaesError(`aaes: the ${what} must be an object`);
1263
+ }
1264
+ for (const key of Object.keys(value)) {
1265
+ if (!allowed.has(key)) {
1266
+ throw new AaesError(
1267
+ `aaes: the ${what} names ${JSON.stringify(key)}, which this client does not send. A caller names a capability and an action: a URL, a method, a header, a credential, a tenant or an actor is not yours to name here`,
1268
+ );
1269
+ }
1270
+ }
1271
+ }
1272
+
1273
+ function reasonOf(body) {
1274
+ if (body && typeof body === "object" && typeof body.reason === "string") return body.reason.trim();
1275
+ return "";
1276
+ }
1277
+
1278
+ /** The reason of a 404 resume answer: the daemon's sentence VERBATIM when the
1279
+ * body stated one, and the canned no-deferral sentence only when it did not
1280
+ * (an older daemon without the route, or a proxy, answers plain text). */
1281
+ function noDeferralReason(rawText) {
1282
+ try {
1283
+ return reasonOf(JSON.parse(String(rawText ?? ""))) || "no deferral suspends this intent";
1284
+ } catch {
1285
+ return "no deferral suspends this intent";
1286
+ }
1287
+ }
1288
+
1289
+ /** The body of a final 503, decoded only once it is surfaced and only into an
1290
+ * object: the daemon's JSON reason reaches the refusal, and a proxy's HTML
1291
+ * maintenance page reads as no stated reason under the same status. */
1292
+ function decodeObjectBody(rawText) {
1293
+ try {
1294
+ const parsed = JSON.parse(String(rawText ?? ""));
1295
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1296
+ } catch {
1297
+ return {};
1298
+ }
1299
+ }
1300
+
1301
+ // requireMinorAmount is the wire's front line against fractional and negative
1302
+ // amounts: money on the wire is an integer count of minor units of one ISO
1303
+ // 4217 currency, and a float cannot state cents honestly. Omitted (undefined)
1304
+ // stays the documented zero. AAES never converts.
1305
+ function requireMinorAmount(value, field) {
1306
+ const amount = value ?? 0;
1307
+ if (!Number.isSafeInteger(amount)) {
1308
+ throw new AaesError("aaes: " + field + " must be an integer count of minor units");
1309
+ }
1310
+ if (amount < 0) {
1311
+ throw new AaesError("aaes: " + field + " cannot be negative");
1312
+ }
1313
+ return amount;
1314
+ }
1315
+
1316
+ // minorUnitsFrom reads a daemon-published minor-unit cap. An omitted cap is
1317
+ // unknown (present false, value null). An explicit zero stays zero and present.
1318
+ function minorUnitsFrom(value, field) {
1319
+ if (value === undefined) return { value: null, present: false };
1320
+ if (!Number.isSafeInteger(value) || value < 0) {
1321
+ throw new AaesError("aaes: " + field + " is not a nonnegative JSON integer");
1322
+ }
1323
+ return { value, present: true };
1324
+ }
1325
+
1326
+ // finiteNumberFrom reads a daemon-published nonnegative number such as
1327
+ // latency_added_ms. Omitted reads as zero; a string, boolean, null, negative
1328
+ // or non-finite value is refused rather than coerced.
1329
+ function finiteNumberFrom(value, field) {
1330
+ if (value === undefined) return 0;
1331
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
1332
+ throw new AaesError("aaes: " + field + " is not a finite nonnegative JSON number");
1333
+ }
1334
+ return value;
1335
+ }
1336
+
1337
+ function prune(value) {
1338
+ const out = {};
1339
+ for (const [key, item] of Object.entries(value)) {
1340
+ if (item === undefined || item === null || item === "") continue;
1341
+ out[key] = item;
1342
+ }
1343
+ return out;
1344
+ }
1345
+
1346
+ // The grant names the canonical capability and version separately; its locator
1347
+ // names that canonical id. A request may pin the pair as canonical@version.
1348
+ // Exact equality wins, so a capability id that itself contains "@" stays the
1349
+ // id; only then does the last "@" start a pin.
1350
+ function federatedCapabilityMatches(requested, grant) {
1351
+ const canonical = String(grant.capability ?? "").trim();
1352
+ const version = String(grant.capabilityVersion ?? "").trim();
1353
+ const locator = String(grant.locator?.capability ?? "").trim();
1354
+ if (!canonical || locator !== canonical) return false;
1355
+ if (requested === canonical) return true;
1356
+ const at = requested.lastIndexOf("@");
1357
+ if (at <= 0 || at === requested.length - 1) return false;
1358
+ const base = requested.slice(0, at).trim();
1359
+ const pin = requested.slice(at + 1).trim();
1360
+ return base === canonical && pin !== "" && pin === version;
1361
+ }
1362
+
1363
+ function explicitCapabilityPin(spelling, entries) {
1364
+ if (!spelling.includes("@")) return false;
1365
+ for (const entry of entries) {
1366
+ if (String(entry.capabilityId ?? "").trim() === spelling) return false;
1367
+ }
1368
+ return true;
1369
+ }
1370
+
1371
+ // onlyMatch returns the single entry a spelling selects, or null. A bare id
1372
+ // listed in several versions, or a pin listed twice, is ambiguous: picking one
1373
+ // by response order would attach another version's tier and task metadata.
1374
+ function onlyMatch(entries, matches) {
1375
+ const found = entries.filter(matches);
1376
+ return found.length === 1 ? found[0] : null;
1377
+ }
1378
+
1379
+ // findCapability resolves an entitled spelling. A full capability id that
1380
+ // itself contains "@" stays that id; otherwise "@" separates a version pin.
1381
+ function findCapability(entries, spelling) {
1382
+ const name = String(spelling ?? "").trim();
1383
+ if (explicitCapabilityPin(name, entries)) {
1384
+ return onlyMatch(entries, (entry) => entry.name === name);
1385
+ }
1386
+ return onlyMatch(entries, (entry) => entry.capabilityId.trim() === name);
1387
+ }
1388
+
1389
+ // findRequestable resolves a catalogue spelling by the same rule: a pin
1390
+ // ("name@version") matches the id and the version, so an ask can never borrow
1391
+ // another version's tier.
1392
+ function findRequestable(entries, spelling) {
1393
+ const name = String(spelling ?? "").trim();
1394
+ if (explicitCapabilityPin(name, entries)) {
1395
+ const at = name.lastIndexOf("@");
1396
+ const base = name.slice(0, at);
1397
+ const version = name.slice(at + 1);
1398
+ return onlyMatch(entries, (entry) => entry.capabilityId.trim() === base && entry.version.trim() === version);
1399
+ }
1400
+ return onlyMatch(entries, (entry) => entry.capabilityId.trim() === name);
1401
+ }
1402
+
1403
+ function capabilityFrom(entry) {
1404
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
1405
+ throw new AaesError("aaes: capability is not a JSON object");
1406
+ }
1407
+ const capabilityId = entry.capability_id ?? "";
1408
+ const version = entry.version ?? "";
1409
+ const pinned = Boolean(entry.pinned);
1410
+ return {
1411
+ capabilityId,
1412
+ version,
1413
+ pinned,
1414
+ kind: entry.kind ?? "",
1415
+ owner: entry.owner ?? "",
1416
+ riskTier: entry.risk_tier ?? "",
1417
+ actions: entry.actions ?? [],
1418
+ resourceId: entry.resource_id ?? "",
1419
+ dataClass: entry.data_class ?? "",
1420
+ custody: entry.custody ?? "",
1421
+ enforcement: entry.enforcement ?? "",
1422
+ taskId: entry.task_id ?? "",
1423
+ callerTaskId: entry.caller_task_id ?? "",
1424
+ expiresAt: entry.expires_at ?? null,
1425
+ source: entry.source ?? "",
1426
+ requestId: entry.request_id ?? "",
1427
+ get name() {
1428
+ return pinned && version ? `${capabilityId}@${version}` : capabilityId;
1429
+ },
1430
+ };
1431
+ }
1432
+
1433
+ function catalogueEntryFrom(entry) {
1434
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
1435
+ throw new AaesError("aaes: catalogue entry is not a JSON object");
1436
+ }
1437
+ const policy = entry.policy ?? {};
1438
+ return {
1439
+ capabilityId: entry.capability_id ?? "",
1440
+ version: entry.version ?? "",
1441
+ kind: entry.kind ?? "",
1442
+ owner: entry.owner ?? "",
1443
+ riskTier: entry.risk_tier ?? "",
1444
+ actions: entry.actions ?? [],
1445
+ resourceId: entry.resource_id ?? "",
1446
+ dataClass: entry.data_class ?? "",
1447
+ custody: entry.custody ?? "",
1448
+ policy: {
1449
+ policyId: policy.policy_id ?? "",
1450
+ label: policy.label ?? "",
1451
+ required: policy.required ?? 0,
1452
+ minHumans: policy.min_humans ?? 0,
1453
+ classes: policy.classes ?? [],
1454
+ authority: policy.authority ?? "",
1455
+ },
1456
+ };
1457
+ }
1458
+
1459
+ function grantFrom(body) {
1460
+ if (body.non_transferable !== true) {
1461
+ throw new AaesError("aaes: grant non_transferable must be JSON true");
1462
+ }
1463
+ const spendCap = minorUnitsFrom(body.spend_cap_minor, "spend_cap_minor");
1464
+ return {
1465
+ grantId: body.grant_id ?? "",
1466
+ actorId: body.actor_id ?? "",
1467
+ tenantId: body.tenant_id ?? "",
1468
+ capability: body.capability ?? "",
1469
+ tier: body.tier ?? "",
1470
+ taskId: body.task_id ?? "",
1471
+ issuedAt: body.issued_at ?? "",
1472
+ expiresAt: body.expires_at ?? "",
1473
+ ttlSeconds: body.ttl_seconds ?? 0,
1474
+ spendCapMinor: spendCap.value,
1475
+ spendCapMinorPresent: spendCap.present,
1476
+ spendCapCurrency: body.spend_cap_currency ?? "",
1477
+ nonTransferable: true,
1478
+ budgetGrantId: body.budget_grant_id ?? "",
1479
+ issuer: body.issuer ?? "",
1480
+ signature: body.signature ?? "",
1481
+ };
1482
+ }
1483
+
1484
+ function grantPresentFrom(body) {
1485
+ const spendCap = minorUnitsFrom(body.spend_cap_minor, "spend_cap_minor");
1486
+ return {
1487
+ grantId: body.grant_id ?? "",
1488
+ actorId: body.actor_id ?? "",
1489
+ tenantId: body.tenant_id ?? "",
1490
+ capability: body.capability ?? "",
1491
+ tier: body.tier ?? "",
1492
+ taskId: body.task_id ?? "",
1493
+ issuedAt: body.issued_at ?? "",
1494
+ expiresAt: body.expires_at ?? "",
1495
+ spendCapMinor: spendCap.value,
1496
+ spendCapMinorPresent: spendCap.present,
1497
+ spendCapCurrency: body.spend_cap_currency ?? "",
1498
+ };
1499
+ }
1500
+
1501
+ function receiptFrom(body) {
1502
+ return {
1503
+ receiptId: body.receipt_id ?? "",
1504
+ intentId: body.intent_id ?? "",
1505
+ tenantId: body.tenant_id ?? "",
1506
+ taskId: body.task_id ?? "",
1507
+ approvalId: body.approval_id ?? "",
1508
+ custody: body.custody ?? "",
1509
+ grantId: body.grant_id ?? "",
1510
+ effectId: body.effect_id ?? "",
1511
+ effect: body.effect ?? "",
1512
+ recordHash: body.record_hash ?? "",
1513
+ issuedAt: body.issued_at ?? "",
1514
+ effectClass: body.class ?? 0,
1515
+ railConfirmed: Boolean(body.rail_confirmed),
1516
+ confirmedAt: body.confirmed_at ?? "",
1517
+ reportedBy: body.reported_by ?? "",
1518
+ reportedAt: body.reported_at ?? "",
1519
+ observedAt: body.observed_at ?? "",
1520
+ reportedOutcome: body.reported_outcome ?? "",
1521
+ note: body.note ?? "",
1522
+ receiptHash: body.receipt_hash ?? "",
1523
+ policyBundleHash: body.policy_bundle_hash ?? "",
1524
+ inputHash: body.input_hash ?? "",
1525
+ engineVersion: body.engine_version ?? "",
1526
+ };
1527
+ }
1528
+
1529
+ /** decodeBase64 turns the daemon's credential into bytes. The wire carries it as
1530
+ * standard base64 -- the encoding a Go []byte marshals to and every SDK decodes.
1531
+ * An undecodable value is NO credential: a half-decoded secret is not one, and
1532
+ * handing one back would be worse than refusing. */
1533
+ function decodeBase64(value) {
1534
+ const raw = String(value ?? "").trim();
1535
+ if (!raw) return new Uint8Array(0);
1536
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(raw) || raw.length % 4 !== 0) {
1537
+ throw new AaesError(
1538
+ "aaes: the daemon's federated credential is not valid base64; this client refuses to hand back a half-decoded secret",
1539
+ );
1540
+ }
1541
+ if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(raw, "base64"));
1542
+ if (typeof atob === "function") {
1543
+ const binary = atob(raw);
1544
+ const out = new Uint8Array(binary.length);
1545
+ for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
1546
+ return out;
1547
+ }
1548
+ throw new AaesError("aaes: this runtime has no base64 decoder, so the federated credential cannot be read");
1549
+ }
1550
+
1551
+ function federatedInstructionsFrom(body = {}) {
1552
+ return new FederatedInstructions({
1553
+ backend: body.backend ?? "",
1554
+ method: body.method ?? "",
1555
+ url: body.url ?? "",
1556
+ headers: body.headers ?? {},
1557
+ form: body.form ?? {},
1558
+ json: body.json ?? {},
1559
+ subjectTokenIn: body.subject_token_in ?? "",
1560
+ subjectTokenField: body.subject_token_field ?? "",
1561
+ subjectTokenType: body.subject_token_type ?? "",
1562
+ durationSeconds: body.duration_seconds ?? 0,
1563
+ documentation: body.documentation ?? "",
1564
+ notBuilt: body.not_built ?? [],
1565
+ notes: body.notes ?? [],
1566
+ });
1567
+ }
1568
+
1569
+ function federatedMaterialFrom(body = {}) {
1570
+ const instructions = body.instructions;
1571
+ return new FederatedMaterial({
1572
+ kind: body.kind ?? "",
1573
+ credential: decodeBase64(body.credential),
1574
+ instructions:
1575
+ instructions !== null && typeof instructions === "object"
1576
+ ? federatedInstructionsFrom(instructions)
1577
+ : null,
1578
+ expiresAt: body.expires_at ?? "",
1579
+ audience: body.audience ?? "",
1580
+ });
1581
+ }
1582
+
1583
+ function federatedGrantFrom(body = {}, intentId = "") {
1584
+ const locator = body.locator ?? {};
1585
+ const record = body.record ?? {};
1586
+ const spendCap = minorUnitsFrom((body.spend_cap ?? {}).minor, "spend_cap.minor");
1587
+ if (record.payload_seen !== true && record.payload_seen !== false) {
1588
+ throw new AaesError(
1589
+ "aaes: the daemon returned a grant whose record omits payload_seen or did not send a JSON boolean",
1590
+ );
1591
+ }
1592
+ return new FederatedGrant({
1593
+ federationId: body.federation_id ?? "",
1594
+ intentId: body.intent_id ?? intentId ?? "",
1595
+ decisionGrantId: body.decision_grant_id ?? "",
1596
+ tenantId: body.tenant_id ?? "",
1597
+ actorId: body.actor_id ?? "",
1598
+ capability: body.capability ?? "",
1599
+ capabilityVersion: body.capability_version ?? "",
1600
+ actions: body.actions ?? [],
1601
+ resourceId: body.resource_id ?? "",
1602
+ taskId: body.task_id ?? "",
1603
+ dataClass: body.data_class ?? "",
1604
+ tier: body.tier ?? "",
1605
+ spendCapMinor: spendCap.value,
1606
+ spendCapMinorPresent: spendCap.present,
1607
+ spendCapCurrency: (body.spend_cap ?? {}).currency ?? "",
1608
+ scopeKey: body.scope_key ?? "",
1609
+ backend: body.backend ?? "",
1610
+ locator: {
1611
+ capability: locator.capability ?? "",
1612
+ endpoint: locator.endpoint ?? "",
1613
+ method: locator.method ?? "",
1614
+ authority: locator.authority ?? "",
1615
+ resourceId: locator.resource_id ?? "",
1616
+ },
1617
+ issuedAt: body.issued_at ?? "",
1618
+ expiresAt: body.expires_at ?? "",
1619
+ ttlSeconds: body.ttl_seconds ?? 0,
1620
+ material: federatedMaterialFrom(body.material ?? {}),
1621
+ record: {
1622
+ intentId: record.intent_id ?? "",
1623
+ mode: record.mode ?? "",
1624
+ payloadSeen: record.payload_seen,
1625
+ markerReceiptHash: record.marker_receipt_hash ?? "",
1626
+ receiptId: record.receipt_id ?? "",
1627
+ },
1628
+ });
1629
+ }
1630
+
1631
+ function accessViewFrom(body) {
1632
+ return {
1633
+ requestId: body.request_id ?? "",
1634
+ state: body.state ?? "",
1635
+ needed: body.needed ?? {},
1636
+ tenantId: body.tenant_id ?? "",
1637
+ actorId: body.actor_id ?? "",
1638
+ capability: body.capability ?? "",
1639
+ actions: body.actions ?? [],
1640
+ resourceId: body.resource_id ?? "",
1641
+ surfaceResourceId: body.surface_resource_id ?? "",
1642
+ teamId: body.team_id ?? "",
1643
+ dataClass: body.data_class ?? "",
1644
+ tier: body.tier ?? "",
1645
+ budgetMinor: body.budget_minor ?? 0,
1646
+ budgetCurrency: body.budget_currency ?? "",
1647
+ ttlSeconds: body.ttl_seconds ?? 0,
1648
+ taskId: body.task_id ?? "",
1649
+ justification: body.justification ?? "",
1650
+ duration: body.duration ?? "",
1651
+ reviewTtlSeconds: body.review_ttl_seconds ?? 0,
1652
+ windowStart: body.window_start ?? "",
1653
+ windowEnd: body.window_end ?? "",
1654
+ windowTimezone: body.window_timezone ?? "",
1655
+ reviewRequired: Boolean(body.review_required),
1656
+ reviewedBy: body.reviewed_by ?? "",
1657
+ reviewedAt: body.reviewed_at ?? null,
1658
+ reviewed: Boolean(body.reviewed),
1659
+ approvalId: body.approval_id ?? "",
1660
+ approvals: body.approvals ?? [],
1661
+ decidedBy: body.decided_by ?? [],
1662
+ decidedAt: body.decided_at ?? null,
1663
+ expiresAt: body.expires_at ?? null,
1664
+ createdAt: body.created_at ?? null,
1665
+ reason: body.reason ?? "",
1666
+ };
1667
+ }
1668
+
1669
+ function sleep(ms) {
1670
+ return new Promise((resolve) => setTimeout(resolve, ms));
1671
+ }
1672
+
1673
+ /** The offline command that checks an exported log. This module verifies
1674
+ * nothing itself: AAES is out of the trust path, and the evidence is checked by
1675
+ * your own binary, key and export. */
1676
+ export function verifyHint(exportPath, publicKeyPath = "") {
1677
+ let hint = "aaesctl verify --export " + shellQuote(exportPath);
1678
+ if (String(publicKeyPath).trim()) hint += " --pubkey " + shellQuote(publicKeyPath);
1679
+ return hint;
1680
+ }
1681
+
1682
+ // shellQuote renders a value for a copy-pasteable shell command. Only a
1683
+ // conservative safe set travels unquoted; anything else is single-quoted.
1684
+ function shellQuote(value) {
1685
+ const text = String(value);
1686
+ if (text === "") return "''";
1687
+ if (/^[A-Za-z0-9@%_+=:,./-]+$/.test(text)) return text;
1688
+ return "'" + text.replaceAll("'", "'\\''") + "'";
1689
+ }
1690
+
1691
+ /** Person-only daemon operations. Construct with a separately issued person token.
1692
+ * Naming this class does not confer a role; the daemon checks it on each request. */
1693
+ export class OperatorClient extends Client {
1694
+ static fromEnv(options = {}) { return new OperatorClient(options); }
1695
+ async revokeAccessRequest(id, { reason = "" } = {}) {
1696
+ const path = "/v1/access-requests/" + recordId(id) + "/revoke";
1697
+ const { status, body } = await this._request("POST", path, { reason }, { identified: false });
1698
+ if (status !== 200) throw this._statusError("access revocation", status, body);
1699
+ return accessViewFrom(body);
1700
+ }
1701
+ }
1702
+
1703
+ function recordId(value) {
1704
+ if (typeof value !== "string" || !value.trim() || value.trim() === "." || value.trim() === ".." || /[\\/\x00-\x1f]/.test(value)) {
1705
+ throw new AaesError("aaes: a non-empty record id without path separators is required");
1706
+ }
1707
+ return encodeURIComponent(value.trim());
1708
+ }
1709
+ function readQuery(filters, mapping) {
1710
+ refuseUnknownKeys(new Set(Object.keys(mapping)), filters, "read filters");
1711
+ const query = new URLSearchParams();
1712
+ for (const [key, value] of Object.entries(filters)) {
1713
+ if (value === undefined || value === null || value === "") continue;
1714
+ if (key === "limit" && (!Number.isInteger(value) || value < 0 || value > 200)) throw new AaesError("aaes: limit must be an integer between 0 and 200; pass 0 or omit it for the daemon default");
1715
+ if (key === "limit" && value === 0) continue;
1716
+ if (key === "overdue" && typeof value !== "boolean") throw new AaesError("aaes: overdue must be boolean");
1717
+ if (key !== "limit" && key !== "overdue" && typeof value !== "string") throw new AaesError("aaes: read filters must be strings");
1718
+ query.set(mapping[key], String(value));
1719
+ }
1720
+ const encoded = query.toString();
1721
+ return encoded ? "?" + encoded : "";
1722
+ }