@axonflow/sdk 9.2.0 → 9.3.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 (76) hide show
  1. package/README.md +121 -0
  2. package/dist/cjs/adapters/langgraph.d.ts.map +1 -1
  3. package/dist/cjs/adapters/langgraph.js +20 -0
  4. package/dist/cjs/adapters/langgraph.js.map +1 -1
  5. package/dist/cjs/authzen.d.ts +35 -10
  6. package/dist/cjs/authzen.d.ts.map +1 -1
  7. package/dist/cjs/authzen.js +45 -19
  8. package/dist/cjs/authzen.js.map +1 -1
  9. package/dist/cjs/client.d.ts +69 -10
  10. package/dist/cjs/client.d.ts.map +1 -1
  11. package/dist/cjs/client.js +338 -52
  12. package/dist/cjs/client.js.map +1 -1
  13. package/dist/cjs/heartbeat.d.ts +55 -4
  14. package/dist/cjs/heartbeat.d.ts.map +1 -1
  15. package/dist/cjs/heartbeat.js +86 -7
  16. package/dist/cjs/heartbeat.js.map +1 -1
  17. package/dist/cjs/index.d.ts +3 -0
  18. package/dist/cjs/index.d.ts.map +1 -1
  19. package/dist/cjs/index.js +12 -2
  20. package/dist/cjs/index.js.map +1 -1
  21. package/dist/cjs/read-identity.d.ts +267 -0
  22. package/dist/cjs/read-identity.d.ts.map +1 -0
  23. package/dist/cjs/read-identity.js +329 -0
  24. package/dist/cjs/read-identity.js.map +1 -0
  25. package/dist/cjs/telemetry.d.ts +108 -0
  26. package/dist/cjs/telemetry.d.ts.map +1 -1
  27. package/dist/cjs/telemetry.js +318 -35
  28. package/dist/cjs/telemetry.js.map +1 -1
  29. package/dist/cjs/types/authzen.gen.d.ts +4 -2
  30. package/dist/cjs/types/authzen.gen.d.ts.map +1 -1
  31. package/dist/cjs/types/authzen.gen.js +10 -4
  32. package/dist/cjs/types/authzen.gen.js.map +1 -1
  33. package/dist/cjs/types/config.d.ts +43 -1
  34. package/dist/cjs/types/config.d.ts.map +1 -1
  35. package/dist/cjs/types/hitl.js +1 -1
  36. package/dist/cjs/types/hitl.js.map +1 -1
  37. package/dist/cjs/version.d.ts +1 -1
  38. package/dist/cjs/version.js +1 -1
  39. package/dist/esm/adapters/langgraph.d.ts.map +1 -1
  40. package/dist/esm/adapters/langgraph.js +20 -0
  41. package/dist/esm/adapters/langgraph.js.map +1 -1
  42. package/dist/esm/authzen.d.ts +35 -10
  43. package/dist/esm/authzen.d.ts.map +1 -1
  44. package/dist/esm/authzen.js +38 -14
  45. package/dist/esm/authzen.js.map +1 -1
  46. package/dist/esm/client.d.ts +69 -10
  47. package/dist/esm/client.d.ts.map +1 -1
  48. package/dist/esm/client.js +338 -52
  49. package/dist/esm/client.js.map +1 -1
  50. package/dist/esm/heartbeat.d.ts +55 -4
  51. package/dist/esm/heartbeat.d.ts.map +1 -1
  52. package/dist/esm/heartbeat.js +85 -7
  53. package/dist/esm/heartbeat.js.map +1 -1
  54. package/dist/esm/index.d.ts +3 -0
  55. package/dist/esm/index.d.ts.map +1 -1
  56. package/dist/esm/index.js +5 -0
  57. package/dist/esm/index.js.map +1 -1
  58. package/dist/esm/read-identity.d.ts +267 -0
  59. package/dist/esm/read-identity.d.ts.map +1 -0
  60. package/dist/esm/read-identity.js +320 -0
  61. package/dist/esm/read-identity.js.map +1 -0
  62. package/dist/esm/telemetry.d.ts +108 -0
  63. package/dist/esm/telemetry.d.ts.map +1 -1
  64. package/dist/esm/telemetry.js +313 -35
  65. package/dist/esm/telemetry.js.map +1 -1
  66. package/dist/esm/types/authzen.gen.d.ts +4 -2
  67. package/dist/esm/types/authzen.gen.d.ts.map +1 -1
  68. package/dist/esm/types/authzen.gen.js +8 -2
  69. package/dist/esm/types/authzen.gen.js.map +1 -1
  70. package/dist/esm/types/config.d.ts +43 -1
  71. package/dist/esm/types/config.d.ts.map +1 -1
  72. package/dist/esm/types/hitl.js +1 -1
  73. package/dist/esm/types/hitl.js.map +1 -1
  74. package/dist/esm/version.d.ts +1 -1
  75. package/dist/esm/version.js +1 -1
  76. package/package.json +1 -1
@@ -5,6 +5,47 @@ import { buildEnvelope, evaluateEnvelope } from './authzen.js';
5
5
  import { AuthenticationError, APIError, PolicyViolationError, ConfigurationError, ConnectorError, PlanExecutionError, RateLimitError, VersionConflictError, IdempotencyKeyMismatchError, ObligationNotFulfillableError, } from './errors.js';
6
6
  import { CONTENT_TYPE_TEXT, DECIDE_PATH, OBLIGATION_REDACT_PII, PHASE_REQUEST, REQUEST_REDACTION_PATH, VERDICT_ALLOW, endpointPathMatches, stripUndefined, } from './pep.js';
7
7
  import { generateRequestId, debugLog } from './utils/helpers.js';
8
+ import { HEADER_USER_TOKEN, applyReadIdentity, readScopeErrorFor, readScopeOf, refuseVacuousScopedPage, stripCredentialsOffOrigin, } from './read-identity.js';
9
+ /**
10
+ * State backing each client's {@link AxonFlow.heartbeatReady}, keyed by the
11
+ * client's `heartbeatRoot`.
12
+ *
13
+ * KEYED ON THE ROOT, NOT ON `this`, AND THAT IS A BUG FIX RATHER THAN A
14
+ * REFINEMENT. `asUser()` derives with `Object.assign(derived, this)`, which
15
+ * copies the `heartbeatReady` PROMISE onto the derived client but cannot copy a
16
+ * WeakMap entry keyed on the parent. Keyed on `this`, a derived client's first
17
+ * request looked up nothing, resolved nothing, and `await derived.heartbeatReady`
18
+ * hung forever — while the README told callers to await exactly that after
19
+ * their first call. A documented deadlock.
20
+ *
21
+ * `heartbeatRoot` is a plain own property, so `Object.assign` copies it and every
22
+ * derived client reaches the same entry. That is also the correct semantics: the
23
+ * heartbeat gate is process-global, there is ONE ping, and the promise copied
24
+ * onto the derived client is the same object — so whichever client sends first
25
+ * settles it for all of them.
26
+ *
27
+ * It is a non-function property, which keeps the `read-identity.test.ts`
28
+ * invariant ("a fresh instance has no own-property functions") green — that test
29
+ * is what caught the first attempt at storing a resolver on the instance.
30
+ */
31
+ const heartbeatReadyState = new WeakMap();
32
+ /**
33
+ * Redirect statuses the identity-aware follower handles, and the hop bound.
34
+ * Mirrors what `fetch` would have done itself, minus the header leak.
35
+ */
36
+ const IDENTITY_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
37
+ const MAX_IDENTITY_REDIRECTS = 10;
38
+ /** Whether `url` is the same origin as the configured endpoint. */
39
+ function sameOriginAs(url, endpoint) {
40
+ if (!endpoint)
41
+ return false;
42
+ try {
43
+ return url.origin === new URL(endpoint).origin;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
8
49
  /**
9
50
  * Extract a typed IdempotencyKeyMismatchError from an APIError with HTTP 409 and
10
51
  * `error.code === "IDEMPOTENCY_KEY_MISMATCH"` in the response body. Returns null if
@@ -99,29 +140,14 @@ export class AxonFlow {
99
140
  // mode-based default-suppression rule are gone.
100
141
  this.interceptors = [];
101
142
  this.sessionCookie = null;
102
- // ------------------------------------------------------------------ //
103
- // Decision Mode PEP: decide -> fulfill -> forward (ADR-056, #2563) //
104
- // ------------------------------------------------------------------ //
105
- // ------------------------------------------------------------------ //
106
- // AuthZEN-native authorization (ADR-065) //
107
- // ------------------------------------------------------------------ //
108
143
  /**
109
- * The transport `src/authzen.ts` runs its envelopes through.
144
+ * Identity of the client that owns this heartbeat promise.
110
145
  *
111
- * It is this client's own authenticated HTTP path same credentials, same
112
- * `X-Axonflow-Client` attribution, same heartbeat gate with the status left
113
- * uninterpreted, because on this route a 4xx body is a typed refusal document
114
- * rather than an error string.
146
+ * `this` on an original client; copied by `Object.assign` onto every client
147
+ * derived from it with `asUser()`, so a derived client settles the SAME
148
+ * promise rather than one nothing can reach. See {@link heartbeatReadyState}.
115
149
  */
116
- this.sendAuthZEN = async (path, body, extraHeaders) => {
117
- const response = await this._fetch(`${this.config.endpoint}${path}`, {
118
- method: 'POST',
119
- headers: { ...this.buildAuthHeaders(), ...extraHeaders },
120
- body: JSON.stringify(body),
121
- signal: AbortSignal.timeout(this.config.timeout),
122
- });
123
- return { status: response.status, body: await response.text() };
124
- };
150
+ this.heartbeatRoot = this;
125
151
  // Configuration validation
126
152
  if (config.clientSecret && !config.clientId) {
127
153
  throw new ConfigurationError('clientSecret requires clientId to be set. ' +
@@ -142,12 +168,49 @@ export class AxonFlow {
142
168
  // always pass an explicit endpoint. The previous default
143
169
  // (staging-eu.getaxonflow.com) was decommissioned 2026-04-09.
144
170
  const endpoint = config.endpoint ?? 'http://localhost:8080';
171
+ // THE `cache` OPTION IS REFUSED, BECAUSE IT NEVER DID ANYTHING
172
+ // (sdk-typescript#267, axonflow-enterprise#3682 item 4).
173
+ //
174
+ // It was accepted, normalised into `this.config`, and read by NO request
175
+ // path. So a caller passing `{ enabled: true, ttl: 60000 }` was making a
176
+ // cost and latency assumption the SDK silently did not honour — and the
177
+ // default resolved to `enabled: true`, so every client reported caching ON
178
+ // and got none.
179
+ //
180
+ // Refusing beats implementing. The four sibling SDKs all shipped a fix this
181
+ // week for the SAME defect in their caches: a client derived with `asUser`
182
+ // shared the parent's cache, and the key carried the write-path body token
183
+ // but NOT the identity the request presents, so two derived clients asking
184
+ // one question hashed to one entry and the second caller was served the
185
+ // first's governed response. Adding a fifth instance of that shape, to
186
+ // close a checklist item, on the one SDK that has no cache at all, is
187
+ // taking on the exact risk the others just paid to remove. If a cache is
188
+ // ever wanted here it gets built deliberately, with the identity in the key
189
+ // from the first commit.
190
+ //
191
+ // ONLY AN EXPLICIT `cache` THROWS. A blanket construction-time throw would
192
+ // break every existing application, including the overwhelming majority
193
+ // that never asked for a cache and merely accepted the default — which,
194
+ // because of that `!== false`, was every one of them. The distinction is
195
+ // the difference between a targeted, informative error and a breaking
196
+ // change for everyone.
197
+ if (config.cache !== undefined) {
198
+ throw new ConfigurationError('The `cache` option is not supported and has never had any effect: it was accepted ' +
199
+ 'and normalised but read by no request path, so responses were never cached. It is ' +
200
+ 'now refused rather than silently ignored, so the assumption is corrected at the ' +
201
+ 'call site instead of at runtime. Remove the option. If you need caching, cache at ' +
202
+ 'your own call site, where you control the key — note that any cache in front of ' +
203
+ 'this SDK MUST include the effective user identity in its key, or a client derived ' +
204
+ 'with asUser() can be served another identity\u2019s governed response. See ' +
205
+ 'https://github.com/getaxonflow/axonflow-sdk-typescript/issues/267');
206
+ }
145
207
  // Credentials check: OAuth2-style (clientId/clientSecret)
146
208
  const hasCredentials = !!(config.clientId && config.clientSecret);
147
209
  // Set configuration
148
210
  this.config = {
149
211
  clientId: config.clientId,
150
212
  clientSecret: config.clientSecret,
213
+ userToken: config.userToken,
151
214
  endpoint,
152
215
  mode: config.mode ?? 'production',
153
216
  tenant: config.tenant ?? '',
@@ -159,10 +222,6 @@ export class AxonFlow {
159
222
  maxAttempts: config.retry?.maxAttempts ?? 3,
160
223
  delay: config.retry?.delay ?? 1000,
161
224
  },
162
- cache: {
163
- enabled: config.cache?.enabled !== false,
164
- ttl: config.cache?.ttl ?? 60000,
165
- },
166
225
  };
167
226
  // Interceptors removed in v3.0.0 (deprecated wrapOpenAIClient/wrapAnthropicClient)
168
227
  this.interceptors = [];
@@ -175,21 +234,38 @@ export class AxonFlow {
175
234
  authMethod,
176
235
  });
177
236
  }
178
- // Heartbeat gate: at most one anonymous ping per environment per
179
- // 7 days, gated by SDK activity. Constructor kicks off the gate AND
180
- // chains the in-flight delivery Promise so `heartbeatReady` resolves
181
- // only once the POST has settled — callers in short-lived processes
182
- // (CLI, Lambda boot) can `await client.heartbeatReady` to guarantee
183
- // delivery before exit. Subsequent gate runs happen async via
184
- // _preRequestHook on every public HTTP request site. See
185
- // src/heartbeat.ts.
186
- this.heartbeatReady = (async () => {
187
- await this._preRequestHook();
188
- const inFlight = flushHeartbeat();
189
- if (inFlight) {
190
- await inFlight;
191
- }
192
- })();
237
+ // NO HEARTBEAT HERE ANY MORE (axonflow-enterprise#3682). The gate is
238
+ // consulted on this client's FIRST OUTBOUND REQUEST instead, in
239
+ // `_preRequestHook`, which every request already passes through.
240
+ //
241
+ // Why: every framework adapter takes a client, so an adapter cannot exist
242
+ // until this constructor has returned. Pinging here meant an adapter
243
+ // registering from its own constructor could never reach the first ping,
244
+ // and the 7-day stamp then suppressed the next one for a week — so a
245
+ // short-lived process using an adapter reported it never. See
246
+ // `registerAdapter` in telemetry.ts.
247
+ //
248
+ // A client that is constructed and never used no longer pings. That is
249
+ // deliberate and disclosed: a heartbeat is a claim about usage.
250
+ //
251
+ // `heartbeatReady` is now settled by the first gate run rather than by a
252
+ // constructor ping — see its docstring; it stays PENDING for a client that
253
+ // never makes a request, which is the honest answer for "has the heartbeat
254
+ // finished" when there was none.
255
+ this.heartbeatReady = new Promise(resolve => {
256
+ // The resolver lives in a module-level WeakMap, NOT on the instance.
257
+ //
258
+ // `asUser()` derives a client with `Object.create`, so an own-property
259
+ // FUNCTION would not be shared the way a prototype method is — which is
260
+ // exactly the invariant `read-identity.test.ts` pins with "a fresh
261
+ // instance has no own-property functions". Storing the resolver as a
262
+ // field broke that test, and the test was right: this is the mechanism
263
+ // that keeps every request-issuing member reachable on a derived client.
264
+ //
265
+ // A WeakMap also means the entry disappears with the client rather than
266
+ // pinning it alive.
267
+ heartbeatReadyState.set(this, { resolve, settled: false });
268
+ });
193
269
  }
194
270
  /**
195
271
  * Single hook invoked at the start of every public HTTP request path
@@ -213,6 +289,25 @@ export class AxonFlow {
213
289
  endpoint: this.config.endpoint,
214
290
  debug: this.config.debug,
215
291
  }));
292
+ // Settle `heartbeatReady` once the FIRST gate run has finished and any
293
+ // delivery it started has settled. Guarded to one call: this hook runs on
294
+ // every request, and re-resolving is harmless but chaining flushHeartbeat
295
+ // on every request would not be.
296
+ const state = heartbeatReadyState.get(this.heartbeatRoot);
297
+ if (state && !state.settled) {
298
+ state.settled = true;
299
+ // AWAITED, not fire-and-forget. `heartbeatReady` exists so a short-lived
300
+ // process can `await` it and then exit without truncating the POST, so
301
+ // resolving before the in-flight delivery settles would make the promise
302
+ // a lie — it would resolve, the caller would `process.exit(0)`, and the
303
+ // ping would be lost. `telemetry-adapter-registry.test.ts` asserts the
304
+ // checkpoint actually received the POST at the moment this resolves.
305
+ const inFlight = flushHeartbeat();
306
+ if (inFlight) {
307
+ await inFlight;
308
+ }
309
+ state.resolve();
310
+ }
216
311
  }
217
312
  /**
218
313
  * Single HTTP wrapper used by every public-API request path. Schedules
@@ -223,9 +318,71 @@ export class AxonFlow {
223
318
  * itself (sendTelemetryPingNow / detectPlatformVersion). Those use raw
224
319
  * `fetch` to avoid recursive heartbeat triggering.
225
320
  */
226
- async _fetch(input, init) {
321
+ async _fetch(input, init, identity) {
227
322
  void this._preRequestHook();
228
- return fetch(input, init);
323
+ // The SDK's ONE identity site. Every request goes through here, and the
324
+ // platform likewise reads X-User-Token once, in the middleware in front of
325
+ // every proxied route rather than per route — so a per-method sprinkle
326
+ // would be a second copy of a decision made in one place on both sides.
327
+ //
328
+ // `identity === undefined` means "this call said nothing", so the
329
+ // client-wide value applies. A string — INCLUDING an empty one — is a
330
+ // deliberate per-call choice and must not fall back, because
331
+ // "unidentified" is a state the platform treats as distinct.
332
+ const token = identity === undefined ? this.config.userToken : identity;
333
+ // The header bag is copied and MUTATED as a plain record, keeping the shape
334
+ // every other caller and test observes on `fetch`. A `Headers` instance
335
+ // here would work on the wire and change what every existing assertion
336
+ // sees, which is a much larger change than an identity fix should be.
337
+ const headers = {
338
+ ...(init?.headers ?? {}),
339
+ };
340
+ let url = new URL(typeof input === 'string' ? input : input.toString());
341
+ applyReadIdentity(headers, url, this.config.endpoint, token ?? undefined);
342
+ if (!(HEADER_USER_TOKEN in headers)) {
343
+ // No credential in flight, so `fetch` keeps its own redirect behaviour
344
+ // rather than acquiring a second, subtly different implementation.
345
+ return fetch(input, init ? { ...init, headers } : { headers });
346
+ }
347
+ // An identity is attached, so redirects are followed BY HAND.
348
+ //
349
+ // The fetch spec strips Authorization on a cross-origin redirect and its
350
+ // list is fixed; X-User-Token is not on it. Measured on Node 25: the
351
+ // redirect target received `authorization: undefined` and
352
+ // `x-user-token: SENTINEL` — the per-user credential outliving the tenant
353
+ // one, on exactly the hop where the caller never named the host. Following
354
+ // by hand re-runs applyReadIdentity per hop, so the identity is dropped the
355
+ // moment the origin changes; the redirect itself is still followed, and the
356
+ // scoped read that lands unscoped then REFUSES visibly rather than quietly
357
+ // answering nothing.
358
+ let request = { ...init, headers, redirect: 'manual' };
359
+ for (let hop = 0; hop <= MAX_IDENTITY_REDIRECTS; hop++) {
360
+ const response = await fetch(url, request);
361
+ const location = response.headers.get('location');
362
+ if (!IDENTITY_REDIRECT_STATUSES.has(response.status) || !location) {
363
+ return response;
364
+ }
365
+ url = new URL(location, url);
366
+ const nextHeaders = { ...headers };
367
+ // EVERY credential is dropped off-origin, not just the identity.
368
+ // `fetch`'s own follower strips Authorization on a cross-origin hop; the
369
+ // moment this code follows by hand that stops happening and becomes this
370
+ // code's job. Dropping only the new header would make setting `userToken`
371
+ // leak `clientSecret` to a host the caller never named — a fix for one
372
+ // credential that exports another.
373
+ if (!sameOriginAs(url, this.config.endpoint)) {
374
+ stripCredentialsOffOrigin(nextHeaders);
375
+ }
376
+ applyReadIdentity(nextHeaders, url, this.config.endpoint, token ?? undefined);
377
+ // 303, and 301/302 on a non-GET, become GET without a body per the spec.
378
+ const method = (init?.method ?? 'GET').toUpperCase();
379
+ const becomesGet = response.status === 303 ||
380
+ ((response.status === 301 || response.status === 302) && method === 'POST');
381
+ request = becomesGet
382
+ ? { ...init, method: 'GET', body: undefined, headers: nextHeaders, redirect: 'manual' }
383
+ : { ...init, headers: nextHeaders, redirect: 'manual' };
384
+ }
385
+ throw new Error(`stopped after ${MAX_IDENTITY_REDIRECTS} redirects`);
229
386
  }
230
387
  /**
231
388
  * Get authentication headers based on configured credentials.
@@ -2157,11 +2314,56 @@ export class AxonFlow {
2157
2314
  * }
2158
2315
  * ```
2159
2316
  */
2160
- async explainDecision(decisionId) {
2317
+ /**
2318
+ * A client identical to this one but presenting `userToken`.
2319
+ *
2320
+ * The shape to reach for when one process acts on behalf of several people —
2321
+ * a gateway, a bot. Unlike the per-call `{ userToken }` option, which only
2322
+ * the read methods accept, this reaches EVERY method: there is no carve-out
2323
+ * to remember and no path on which the identity silently widens back to the
2324
+ * process's own.
2325
+ *
2326
+ * ```typescript
2327
+ * const forAlice = client.asUser(aliceToken);
2328
+ * const rows = await forAlice.listDecisions();
2329
+ * ```
2330
+ *
2331
+ * The returned client shares this one's interceptors and session cookie —
2332
+ * it is a view, not a new connection pool — so deriving one per request is
2333
+ * cheap.
2334
+ *
2335
+ * It shares no CACHE, because this SDK has none: the `cache` option was
2336
+ * inert and is now refused (sdk-typescript#267). That absence is why this
2337
+ * SDK was never exposed to the cross-user response leak the other four
2338
+ * fixed — there is no shared entry to serve to the wrong identity. It is an
2339
+ * absence of the mechanism, not a defence, which is exactly why any cache
2340
+ * built here later must be identity-keyed from the first commit. Only the identity differs; this client is not modified.
2341
+ * The session cookie is copied by value, so a `loginToPortal` on either after
2342
+ * the derivation is invisible to the other; derive after logging in if the
2343
+ * derived client needs the portal plane, which authenticates with the cookie
2344
+ * rather than with this identity.
2345
+ *
2346
+ * An empty token returns a client presenting no identity at all, which on an
2347
+ * enterprise stack reads nothing (see `ReadScope.None`).
2348
+ */
2349
+ asUser(userToken) {
2350
+ const derived = Object.create(Object.getPrototypeOf(this));
2351
+ Object.assign(derived, this);
2352
+ // `config` is replaced rather than mutated, so the derivation cannot reach
2353
+ // back into the client it came from — the failure that would make asUser
2354
+ // silently change the ORIGINAL caller's identity.
2355
+ const config = this.config;
2356
+ derived.config = {
2357
+ ...config,
2358
+ userToken: (userToken ?? '').trim() || undefined,
2359
+ };
2360
+ return derived;
2361
+ }
2362
+ async explainDecision(decisionId, options) {
2161
2363
  if (!decisionId) {
2162
2364
  throw new Error('decisionId is required');
2163
2365
  }
2164
- const response = await this.orchestratorRequest('GET', `/api/v1/decisions/${encodeURIComponent(decisionId)}/explain`);
2366
+ const response = await this.orchestratorRequest('GET', `/api/v1/decisions/${encodeURIComponent(decisionId)}/explain`, undefined, { resource: 'decision', identifier: decisionId, userToken: options?.userToken });
2165
2367
  return this.parseDecisionExplanation(response);
2166
2368
  }
2167
2369
  /**
@@ -2190,7 +2392,7 @@ export class AxonFlow {
2190
2392
  * }
2191
2393
  * ```
2192
2394
  */
2193
- async listDecisions(opts) {
2395
+ async listDecisions(opts, options) {
2194
2396
  const qs = buildListDecisionsQuery(opts);
2195
2397
  const path = qs ? `/api/v1/decisions?${qs}` : '/api/v1/decisions';
2196
2398
  // Hand-roll the request so we can branch on 429 BEFORE
@@ -2201,7 +2403,7 @@ export class AxonFlow {
2201
2403
  method: 'GET',
2202
2404
  headers,
2203
2405
  signal: AbortSignal.timeout(this.config.timeout),
2204
- });
2406
+ }, options?.userToken);
2205
2407
  if (response.status === 429) {
2206
2408
  const text = await response.text();
2207
2409
  try {
@@ -2227,8 +2429,48 @@ export class AxonFlow {
2227
2429
  }
2228
2430
  const body = (await response.json());
2229
2431
  const rows = body?.decisions ?? [];
2432
+ // An empty page under ReadScope.None is the fail-closed shape, not a
2433
+ // finding: the platform returned zero rows because it resolved no identity
2434
+ // to scope on, so the page says nothing about what exists. Guarded on
2435
+ // emptiness as well as on the scope, so a non-empty page is never turned
2436
+ // into an error whatever the header says — and only `none` refuses, because
2437
+ // an own-rows read that legitimately found nothing is a real answer.
2438
+ const scopeError = refuseVacuousScopedPage(response, 'decisions', rows.length);
2439
+ if (scopeError)
2440
+ throw scopeError;
2230
2441
  return rows.map(parseDecisionSummary);
2231
2442
  }
2443
+ // ------------------------------------------------------------------ //
2444
+ // Decision Mode PEP: decide -> fulfill -> forward (ADR-056, #2563) //
2445
+ // ------------------------------------------------------------------ //
2446
+ // ------------------------------------------------------------------ //
2447
+ // AuthZEN-native authorization (ADR-065) //
2448
+ // ------------------------------------------------------------------ //
2449
+ /**
2450
+ * The transport `src/authzen.ts` runs its envelopes through.
2451
+ *
2452
+ * It is this client's own authenticated HTTP path — same credentials, same
2453
+ * `X-Axonflow-Client` attribution, same heartbeat gate — with the status left
2454
+ * uninterpreted, because on this route a 4xx body is a typed refusal document
2455
+ * rather than an error string.
2456
+ */
2457
+ //
2458
+ // A PROTOTYPE METHOD, not a class-field arrow. As a field it was an own
2459
+ // enumerable property holding the parent's lexical `this`, so `asUser`'s
2460
+ // Object.assign copied it and every `evaluate()` on a derived client sent the
2461
+ // PARENT's identity — from construction, not merely after some ordering. A
2462
+ // prototype method has no captured `this`; it binds at the call site, which is
2463
+ // the derived client. See `asUser` and
2464
+ // `a fresh instance has no own-property functions`.
2465
+ async sendAuthZEN(path, body, extraHeaders) {
2466
+ const response = await this._fetch(`${this.config.endpoint}${path}`, {
2467
+ method: 'POST',
2468
+ headers: { ...this.buildAuthHeaders(), ...extraHeaders },
2469
+ body: JSON.stringify(body),
2470
+ signal: AbortSignal.timeout(this.config.timeout),
2471
+ });
2472
+ return { status: response.status, body: await response.text() };
2473
+ }
2232
2474
  /**
2233
2475
  * Ask whether one subject may perform one action on one resource.
2234
2476
  *
@@ -2258,7 +2500,11 @@ export class AxonFlow {
2258
2500
  * before the route ran.
2259
2501
  */
2260
2502
  async evaluate(request) {
2261
- return evaluateEnvelope(this.sendAuthZEN, buildEnvelope(request));
2503
+ // Bound at the CALL SITE, so the transport belongs to whichever client
2504
+ // is calling — including one derived by `asUser`. Passing the method
2505
+ // unbound would lose `this` entirely; capturing it in a field would
2506
+ // reintroduce the parent-bound copy this method exists to avoid.
2507
+ return evaluateEnvelope(this.sendAuthZEN.bind(this), buildEnvelope(request));
2262
2508
  }
2263
2509
  /**
2264
2510
  * Ask whether ONE operation is permitted against several preconditions.
@@ -2287,7 +2533,7 @@ export class AxonFlow {
2287
2533
  * ```
2288
2534
  */
2289
2535
  async evaluateAll(bulk) {
2290
- return evaluateEnvelope(this.sendAuthZEN, buildEnvelope(undefined, bulk));
2536
+ return evaluateEnvelope(this.sendAuthZEN.bind(this), buildEnvelope(undefined, bulk));
2291
2537
  }
2292
2538
  /**
2293
2539
  * Ask the PDP for a verdict on a request (`POST /api/v1/decide`).
@@ -2539,7 +2785,17 @@ export class AxonFlow {
2539
2785
  if (this.config.debug) {
2540
2786
  debugLog('Searching audit logs', { limit, offset });
2541
2787
  }
2542
- const response = await this.orchestratorRequest('POST', '/api/v1/audit/search', body);
2788
+ // The audit reads are in the same role-scoped family as decisions
2789
+ // (platform/orchestrator applyReadScopeHeader), so they inherit the same
2790
+ // rule: an empty page under scope `none` could not have contained a row,
2791
+ // and reporting it as data is the vacuous read this SDK now refuses. The
2792
+ // count is taken inside orchestratorRequest, which sees BOTH shapes the
2793
+ // platform sends — a bare array or the wrapped envelope — so the rule
2794
+ // cannot hold on only whichever branch the server happened to take.
2795
+ const response = await this.orchestratorRequest('POST', '/api/v1/audit/search', body, {
2796
+ resource: 'audit entries',
2797
+ pageKey: 'entries',
2798
+ });
2543
2799
  // Handle both array and wrapped response formats
2544
2800
  if (Array.isArray(response)) {
2545
2801
  const entries = response.map(e => this.parseAuditLogEntry(e));
@@ -2593,7 +2849,11 @@ export class AxonFlow {
2593
2849
  debugLog('Getting audit logs for tenant', { tenantId, limit, offset });
2594
2850
  }
2595
2851
  const path = `/api/v1/audit/tenant/${encodeURIComponent(tenantId)}?limit=${limit}&offset=${offset}`;
2596
- const response = await this.orchestratorRequest('GET', path);
2852
+ // Same rule, same family as searchAuditLogs.
2853
+ const response = await this.orchestratorRequest('GET', path, undefined, {
2854
+ resource: 'audit entries',
2855
+ pageKey: 'entries',
2856
+ });
2597
2857
  // Handle both array and wrapped response formats
2598
2858
  if (Array.isArray(response)) {
2599
2859
  const entries = response.map(e => this.parseAuditLogEntry(e));
@@ -3814,7 +4074,7 @@ export class AxonFlow {
3814
4074
  /**
3815
4075
  * Generic HTTP request helper for APIs (routes through single endpoint per ADR-026)
3816
4076
  */
3817
- async orchestratorRequest(method, path, body) {
4077
+ async orchestratorRequest(method, path, body, scoped) {
3818
4078
  const url = `${this.config.endpoint}${path}`;
3819
4079
  const headers = this.buildAuthHeaders();
3820
4080
  const options = {
@@ -3825,9 +4085,24 @@ export class AxonFlow {
3825
4085
  if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
3826
4086
  options.body = JSON.stringify(body);
3827
4087
  }
3828
- const response = await this._fetch(url, options);
4088
+ const response = await this._fetch(url, options, scoped?.userToken);
3829
4089
  if (!response.ok) {
3830
4090
  const errorText = await response.text();
4091
+ // A scoped miss reports WHY it missed. Only 404 is interpreted: the scope
4092
+ // header is stamped before the handler writes its status, so it also
4093
+ // rides a 500 from further down the handler, and explaining a server
4094
+ // fault as a scoping outcome would be exactly the confidently-wrong
4095
+ // diagnosis this type exists to prevent.
4096
+ if (scoped && response.status === 404) {
4097
+ const scopeError = readScopeErrorFor({
4098
+ resource: scoped.resource,
4099
+ identifier: scoped.identifier,
4100
+ scope: readScopeOf(response),
4101
+ statusCode: response.status,
4102
+ });
4103
+ if (scopeError)
4104
+ throw scopeError;
4105
+ }
3831
4106
  if (response.status === 401 || response.status === 403) {
3832
4107
  throw new AuthenticationError(`Request failed: ${errorText}`);
3833
4108
  }
@@ -3840,7 +4115,18 @@ export class AxonFlow {
3840
4115
  if (response.status === 204) {
3841
4116
  return undefined;
3842
4117
  }
3843
- return response.json();
4118
+ const parsed = (await response.json());
4119
+ if (scoped?.pageKey !== undefined) {
4120
+ const rows = Array.isArray(parsed)
4121
+ ? parsed.length
4122
+ : Array.isArray(parsed?.[scoped.pageKey])
4123
+ ? parsed[scoped.pageKey].length
4124
+ : 0;
4125
+ const scopeError = refuseVacuousScopedPage(response, scoped.resource, rows);
4126
+ if (scopeError)
4127
+ throw scopeError;
4128
+ }
4129
+ return parsed;
3844
4130
  }
3845
4131
  // Note: getPortalUrl() was removed in v2.0.0 (ADR-026 Single Entry Point).
3846
4132
  // All routes now go through the single Agent endpoint (this.config.endpoint).