@axonflow/sdk 9.1.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 +238 -22
  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 +353 -0
  6. package/dist/cjs/authzen.d.ts.map +1 -0
  7. package/dist/cjs/authzen.js +901 -0
  8. package/dist/cjs/authzen.js.map +1 -0
  9. package/dist/cjs/client.d.ts +135 -9
  10. package/dist/cjs/client.d.ts.map +1 -1
  11. package/dist/cjs/client.js +397 -30
  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 +6 -0
  18. package/dist/cjs/index.d.ts.map +1 -1
  19. package/dist/cjs/index.js +55 -1
  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 +176 -13
  26. package/dist/cjs/telemetry.d.ts.map +1 -1
  27. package/dist/cjs/telemetry.js +403 -37
  28. package/dist/cjs/telemetry.js.map +1 -1
  29. package/dist/cjs/types/authzen.gen.d.ts +407 -0
  30. package/dist/cjs/types/authzen.gen.d.ts.map +1 -0
  31. package/dist/cjs/types/authzen.gen.js +804 -0
  32. package/dist/cjs/types/authzen.gen.js.map +1 -0
  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 +353 -0
  43. package/dist/esm/authzen.d.ts.map +1 -0
  44. package/dist/esm/authzen.js +886 -0
  45. package/dist/esm/authzen.js.map +1 -0
  46. package/dist/esm/client.d.ts +135 -9
  47. package/dist/esm/client.d.ts.map +1 -1
  48. package/dist/esm/client.js +397 -30
  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 +6 -0
  55. package/dist/esm/index.d.ts.map +1 -1
  56. package/dist/esm/index.js +17 -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 +176 -13
  63. package/dist/esm/telemetry.d.ts.map +1 -1
  64. package/dist/esm/telemetry.js +397 -37
  65. package/dist/esm/telemetry.js.map +1 -1
  66. package/dist/esm/types/authzen.gen.d.ts +407 -0
  67. package/dist/esm/types/authzen.gen.d.ts.map +1 -0
  68. package/dist/esm/types/authzen.gen.js +779 -0
  69. package/dist/esm/types/authzen.gen.js.map +1 -0
  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,9 +5,51 @@ exports.buildListDecisionsQuery = buildListDecisionsQuery;
5
5
  const version_1 = require("./version");
6
6
  const heartbeat_1 = require("./heartbeat");
7
7
  const telemetry_1 = require("./telemetry");
8
+ const authzen_1 = require("./authzen");
8
9
  const errors_1 = require("./errors");
9
10
  const pep_1 = require("./pep");
10
11
  const helpers_1 = require("./utils/helpers");
12
+ const read_identity_1 = require("./read-identity");
13
+ /**
14
+ * State backing each client's {@link AxonFlow.heartbeatReady}, keyed by the
15
+ * client's `heartbeatRoot`.
16
+ *
17
+ * KEYED ON THE ROOT, NOT ON `this`, AND THAT IS A BUG FIX RATHER THAN A
18
+ * REFINEMENT. `asUser()` derives with `Object.assign(derived, this)`, which
19
+ * copies the `heartbeatReady` PROMISE onto the derived client but cannot copy a
20
+ * WeakMap entry keyed on the parent. Keyed on `this`, a derived client's first
21
+ * request looked up nothing, resolved nothing, and `await derived.heartbeatReady`
22
+ * hung forever — while the README told callers to await exactly that after
23
+ * their first call. A documented deadlock.
24
+ *
25
+ * `heartbeatRoot` is a plain own property, so `Object.assign` copies it and every
26
+ * derived client reaches the same entry. That is also the correct semantics: the
27
+ * heartbeat gate is process-global, there is ONE ping, and the promise copied
28
+ * onto the derived client is the same object — so whichever client sends first
29
+ * settles it for all of them.
30
+ *
31
+ * It is a non-function property, which keeps the `read-identity.test.ts`
32
+ * invariant ("a fresh instance has no own-property functions") green — that test
33
+ * is what caught the first attempt at storing a resolver on the instance.
34
+ */
35
+ const heartbeatReadyState = new WeakMap();
36
+ /**
37
+ * Redirect statuses the identity-aware follower handles, and the hop bound.
38
+ * Mirrors what `fetch` would have done itself, minus the header leak.
39
+ */
40
+ const IDENTITY_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
41
+ const MAX_IDENTITY_REDIRECTS = 10;
42
+ /** Whether `url` is the same origin as the configured endpoint. */
43
+ function sameOriginAs(url, endpoint) {
44
+ if (!endpoint)
45
+ return false;
46
+ try {
47
+ return url.origin === new URL(endpoint).origin;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
11
53
  /**
12
54
  * Extract a typed IdempotencyKeyMismatchError from an APIError with HTTP 409 and
13
55
  * `error.code === "IDEMPOTENCY_KEY_MISMATCH"` in the response body. Returns null if
@@ -102,6 +144,14 @@ class AxonFlow {
102
144
  // mode-based default-suppression rule are gone.
103
145
  this.interceptors = [];
104
146
  this.sessionCookie = null;
147
+ /**
148
+ * Identity of the client that owns this heartbeat promise.
149
+ *
150
+ * `this` on an original client; copied by `Object.assign` onto every client
151
+ * derived from it with `asUser()`, so a derived client settles the SAME
152
+ * promise rather than one nothing can reach. See {@link heartbeatReadyState}.
153
+ */
154
+ this.heartbeatRoot = this;
105
155
  // Configuration validation
106
156
  if (config.clientSecret && !config.clientId) {
107
157
  throw new errors_1.ConfigurationError('clientSecret requires clientId to be set. ' +
@@ -122,12 +172,49 @@ class AxonFlow {
122
172
  // always pass an explicit endpoint. The previous default
123
173
  // (staging-eu.getaxonflow.com) was decommissioned 2026-04-09.
124
174
  const endpoint = config.endpoint ?? 'http://localhost:8080';
175
+ // THE `cache` OPTION IS REFUSED, BECAUSE IT NEVER DID ANYTHING
176
+ // (sdk-typescript#267, axonflow-enterprise#3682 item 4).
177
+ //
178
+ // It was accepted, normalised into `this.config`, and read by NO request
179
+ // path. So a caller passing `{ enabled: true, ttl: 60000 }` was making a
180
+ // cost and latency assumption the SDK silently did not honour — and the
181
+ // default resolved to `enabled: true`, so every client reported caching ON
182
+ // and got none.
183
+ //
184
+ // Refusing beats implementing. The four sibling SDKs all shipped a fix this
185
+ // week for the SAME defect in their caches: a client derived with `asUser`
186
+ // shared the parent's cache, and the key carried the write-path body token
187
+ // but NOT the identity the request presents, so two derived clients asking
188
+ // one question hashed to one entry and the second caller was served the
189
+ // first's governed response. Adding a fifth instance of that shape, to
190
+ // close a checklist item, on the one SDK that has no cache at all, is
191
+ // taking on the exact risk the others just paid to remove. If a cache is
192
+ // ever wanted here it gets built deliberately, with the identity in the key
193
+ // from the first commit.
194
+ //
195
+ // ONLY AN EXPLICIT `cache` THROWS. A blanket construction-time throw would
196
+ // break every existing application, including the overwhelming majority
197
+ // that never asked for a cache and merely accepted the default — which,
198
+ // because of that `!== false`, was every one of them. The distinction is
199
+ // the difference between a targeted, informative error and a breaking
200
+ // change for everyone.
201
+ if (config.cache !== undefined) {
202
+ throw new errors_1.ConfigurationError('The `cache` option is not supported and has never had any effect: it was accepted ' +
203
+ 'and normalised but read by no request path, so responses were never cached. It is ' +
204
+ 'now refused rather than silently ignored, so the assumption is corrected at the ' +
205
+ 'call site instead of at runtime. Remove the option. If you need caching, cache at ' +
206
+ 'your own call site, where you control the key — note that any cache in front of ' +
207
+ 'this SDK MUST include the effective user identity in its key, or a client derived ' +
208
+ 'with asUser() can be served another identity\u2019s governed response. See ' +
209
+ 'https://github.com/getaxonflow/axonflow-sdk-typescript/issues/267');
210
+ }
125
211
  // Credentials check: OAuth2-style (clientId/clientSecret)
126
212
  const hasCredentials = !!(config.clientId && config.clientSecret);
127
213
  // Set configuration
128
214
  this.config = {
129
215
  clientId: config.clientId,
130
216
  clientSecret: config.clientSecret,
217
+ userToken: config.userToken,
131
218
  endpoint,
132
219
  mode: config.mode ?? 'production',
133
220
  tenant: config.tenant ?? '',
@@ -139,10 +226,6 @@ class AxonFlow {
139
226
  maxAttempts: config.retry?.maxAttempts ?? 3,
140
227
  delay: config.retry?.delay ?? 1000,
141
228
  },
142
- cache: {
143
- enabled: config.cache?.enabled !== false,
144
- ttl: config.cache?.ttl ?? 60000,
145
- },
146
229
  };
147
230
  // Interceptors removed in v3.0.0 (deprecated wrapOpenAIClient/wrapAnthropicClient)
148
231
  this.interceptors = [];
@@ -155,21 +238,38 @@ class AxonFlow {
155
238
  authMethod,
156
239
  });
157
240
  }
158
- // Heartbeat gate: at most one anonymous ping per environment per
159
- // 7 days, gated by SDK activity. Constructor kicks off the gate AND
160
- // chains the in-flight delivery Promise so `heartbeatReady` resolves
161
- // only once the POST has settled — callers in short-lived processes
162
- // (CLI, Lambda boot) can `await client.heartbeatReady` to guarantee
163
- // delivery before exit. Subsequent gate runs happen async via
164
- // _preRequestHook on every public HTTP request site. See
165
- // src/heartbeat.ts.
166
- this.heartbeatReady = (async () => {
167
- await this._preRequestHook();
168
- const inFlight = (0, heartbeat_1.flushHeartbeat)();
169
- if (inFlight) {
170
- await inFlight;
171
- }
172
- })();
241
+ // NO HEARTBEAT HERE ANY MORE (axonflow-enterprise#3682). The gate is
242
+ // consulted on this client's FIRST OUTBOUND REQUEST instead, in
243
+ // `_preRequestHook`, which every request already passes through.
244
+ //
245
+ // Why: every framework adapter takes a client, so an adapter cannot exist
246
+ // until this constructor has returned. Pinging here meant an adapter
247
+ // registering from its own constructor could never reach the first ping,
248
+ // and the 7-day stamp then suppressed the next one for a week — so a
249
+ // short-lived process using an adapter reported it never. See
250
+ // `registerAdapter` in telemetry.ts.
251
+ //
252
+ // A client that is constructed and never used no longer pings. That is
253
+ // deliberate and disclosed: a heartbeat is a claim about usage.
254
+ //
255
+ // `heartbeatReady` is now settled by the first gate run rather than by a
256
+ // constructor ping — see its docstring; it stays PENDING for a client that
257
+ // never makes a request, which is the honest answer for "has the heartbeat
258
+ // finished" when there was none.
259
+ this.heartbeatReady = new Promise(resolve => {
260
+ // The resolver lives in a module-level WeakMap, NOT on the instance.
261
+ //
262
+ // `asUser()` derives a client with `Object.create`, so an own-property
263
+ // FUNCTION would not be shared the way a prototype method is — which is
264
+ // exactly the invariant `read-identity.test.ts` pins with "a fresh
265
+ // instance has no own-property functions". Storing the resolver as a
266
+ // field broke that test, and the test was right: this is the mechanism
267
+ // that keeps every request-issuing member reachable on a derived client.
268
+ //
269
+ // A WeakMap also means the entry disappears with the client rather than
270
+ // pinning it alive.
271
+ heartbeatReadyState.set(this, { resolve, settled: false });
272
+ });
173
273
  }
174
274
  /**
175
275
  * Single hook invoked at the start of every public HTTP request path
@@ -193,6 +293,25 @@ class AxonFlow {
193
293
  endpoint: this.config.endpoint,
194
294
  debug: this.config.debug,
195
295
  }));
296
+ // Settle `heartbeatReady` once the FIRST gate run has finished and any
297
+ // delivery it started has settled. Guarded to one call: this hook runs on
298
+ // every request, and re-resolving is harmless but chaining flushHeartbeat
299
+ // on every request would not be.
300
+ const state = heartbeatReadyState.get(this.heartbeatRoot);
301
+ if (state && !state.settled) {
302
+ state.settled = true;
303
+ // AWAITED, not fire-and-forget. `heartbeatReady` exists so a short-lived
304
+ // process can `await` it and then exit without truncating the POST, so
305
+ // resolving before the in-flight delivery settles would make the promise
306
+ // a lie — it would resolve, the caller would `process.exit(0)`, and the
307
+ // ping would be lost. `telemetry-adapter-registry.test.ts` asserts the
308
+ // checkpoint actually received the POST at the moment this resolves.
309
+ const inFlight = (0, heartbeat_1.flushHeartbeat)();
310
+ if (inFlight) {
311
+ await inFlight;
312
+ }
313
+ state.resolve();
314
+ }
196
315
  }
197
316
  /**
198
317
  * Single HTTP wrapper used by every public-API request path. Schedules
@@ -203,9 +322,71 @@ class AxonFlow {
203
322
  * itself (sendTelemetryPingNow / detectPlatformVersion). Those use raw
204
323
  * `fetch` to avoid recursive heartbeat triggering.
205
324
  */
206
- async _fetch(input, init) {
325
+ async _fetch(input, init, identity) {
207
326
  void this._preRequestHook();
208
- return fetch(input, init);
327
+ // The SDK's ONE identity site. Every request goes through here, and the
328
+ // platform likewise reads X-User-Token once, in the middleware in front of
329
+ // every proxied route rather than per route — so a per-method sprinkle
330
+ // would be a second copy of a decision made in one place on both sides.
331
+ //
332
+ // `identity === undefined` means "this call said nothing", so the
333
+ // client-wide value applies. A string — INCLUDING an empty one — is a
334
+ // deliberate per-call choice and must not fall back, because
335
+ // "unidentified" is a state the platform treats as distinct.
336
+ const token = identity === undefined ? this.config.userToken : identity;
337
+ // The header bag is copied and MUTATED as a plain record, keeping the shape
338
+ // every other caller and test observes on `fetch`. A `Headers` instance
339
+ // here would work on the wire and change what every existing assertion
340
+ // sees, which is a much larger change than an identity fix should be.
341
+ const headers = {
342
+ ...(init?.headers ?? {}),
343
+ };
344
+ let url = new URL(typeof input === 'string' ? input : input.toString());
345
+ (0, read_identity_1.applyReadIdentity)(headers, url, this.config.endpoint, token ?? undefined);
346
+ if (!(read_identity_1.HEADER_USER_TOKEN in headers)) {
347
+ // No credential in flight, so `fetch` keeps its own redirect behaviour
348
+ // rather than acquiring a second, subtly different implementation.
349
+ return fetch(input, init ? { ...init, headers } : { headers });
350
+ }
351
+ // An identity is attached, so redirects are followed BY HAND.
352
+ //
353
+ // The fetch spec strips Authorization on a cross-origin redirect and its
354
+ // list is fixed; X-User-Token is not on it. Measured on Node 25: the
355
+ // redirect target received `authorization: undefined` and
356
+ // `x-user-token: SENTINEL` — the per-user credential outliving the tenant
357
+ // one, on exactly the hop where the caller never named the host. Following
358
+ // by hand re-runs applyReadIdentity per hop, so the identity is dropped the
359
+ // moment the origin changes; the redirect itself is still followed, and the
360
+ // scoped read that lands unscoped then REFUSES visibly rather than quietly
361
+ // answering nothing.
362
+ let request = { ...init, headers, redirect: 'manual' };
363
+ for (let hop = 0; hop <= MAX_IDENTITY_REDIRECTS; hop++) {
364
+ const response = await fetch(url, request);
365
+ const location = response.headers.get('location');
366
+ if (!IDENTITY_REDIRECT_STATUSES.has(response.status) || !location) {
367
+ return response;
368
+ }
369
+ url = new URL(location, url);
370
+ const nextHeaders = { ...headers };
371
+ // EVERY credential is dropped off-origin, not just the identity.
372
+ // `fetch`'s own follower strips Authorization on a cross-origin hop; the
373
+ // moment this code follows by hand that stops happening and becomes this
374
+ // code's job. Dropping only the new header would make setting `userToken`
375
+ // leak `clientSecret` to a host the caller never named — a fix for one
376
+ // credential that exports another.
377
+ if (!sameOriginAs(url, this.config.endpoint)) {
378
+ (0, read_identity_1.stripCredentialsOffOrigin)(nextHeaders);
379
+ }
380
+ (0, read_identity_1.applyReadIdentity)(nextHeaders, url, this.config.endpoint, token ?? undefined);
381
+ // 303, and 301/302 on a non-GET, become GET without a body per the spec.
382
+ const method = (init?.method ?? 'GET').toUpperCase();
383
+ const becomesGet = response.status === 303 ||
384
+ ((response.status === 301 || response.status === 302) && method === 'POST');
385
+ request = becomesGet
386
+ ? { ...init, method: 'GET', body: undefined, headers: nextHeaders, redirect: 'manual' }
387
+ : { ...init, headers: nextHeaders, redirect: 'manual' };
388
+ }
389
+ throw new Error(`stopped after ${MAX_IDENTITY_REDIRECTS} redirects`);
209
390
  }
210
391
  /**
211
392
  * Get authentication headers based on configured credentials.
@@ -2137,11 +2318,56 @@ class AxonFlow {
2137
2318
  * }
2138
2319
  * ```
2139
2320
  */
2140
- async explainDecision(decisionId) {
2321
+ /**
2322
+ * A client identical to this one but presenting `userToken`.
2323
+ *
2324
+ * The shape to reach for when one process acts on behalf of several people —
2325
+ * a gateway, a bot. Unlike the per-call `{ userToken }` option, which only
2326
+ * the read methods accept, this reaches EVERY method: there is no carve-out
2327
+ * to remember and no path on which the identity silently widens back to the
2328
+ * process's own.
2329
+ *
2330
+ * ```typescript
2331
+ * const forAlice = client.asUser(aliceToken);
2332
+ * const rows = await forAlice.listDecisions();
2333
+ * ```
2334
+ *
2335
+ * The returned client shares this one's interceptors and session cookie —
2336
+ * it is a view, not a new connection pool — so deriving one per request is
2337
+ * cheap.
2338
+ *
2339
+ * It shares no CACHE, because this SDK has none: the `cache` option was
2340
+ * inert and is now refused (sdk-typescript#267). That absence is why this
2341
+ * SDK was never exposed to the cross-user response leak the other four
2342
+ * fixed — there is no shared entry to serve to the wrong identity. It is an
2343
+ * absence of the mechanism, not a defence, which is exactly why any cache
2344
+ * built here later must be identity-keyed from the first commit. Only the identity differs; this client is not modified.
2345
+ * The session cookie is copied by value, so a `loginToPortal` on either after
2346
+ * the derivation is invisible to the other; derive after logging in if the
2347
+ * derived client needs the portal plane, which authenticates with the cookie
2348
+ * rather than with this identity.
2349
+ *
2350
+ * An empty token returns a client presenting no identity at all, which on an
2351
+ * enterprise stack reads nothing (see `ReadScope.None`).
2352
+ */
2353
+ asUser(userToken) {
2354
+ const derived = Object.create(Object.getPrototypeOf(this));
2355
+ Object.assign(derived, this);
2356
+ // `config` is replaced rather than mutated, so the derivation cannot reach
2357
+ // back into the client it came from — the failure that would make asUser
2358
+ // silently change the ORIGINAL caller's identity.
2359
+ const config = this.config;
2360
+ derived.config = {
2361
+ ...config,
2362
+ userToken: (userToken ?? '').trim() || undefined,
2363
+ };
2364
+ return derived;
2365
+ }
2366
+ async explainDecision(decisionId, options) {
2141
2367
  if (!decisionId) {
2142
2368
  throw new Error('decisionId is required');
2143
2369
  }
2144
- const response = await this.orchestratorRequest('GET', `/api/v1/decisions/${encodeURIComponent(decisionId)}/explain`);
2370
+ const response = await this.orchestratorRequest('GET', `/api/v1/decisions/${encodeURIComponent(decisionId)}/explain`, undefined, { resource: 'decision', identifier: decisionId, userToken: options?.userToken });
2145
2371
  return this.parseDecisionExplanation(response);
2146
2372
  }
2147
2373
  /**
@@ -2170,7 +2396,7 @@ class AxonFlow {
2170
2396
  * }
2171
2397
  * ```
2172
2398
  */
2173
- async listDecisions(opts) {
2399
+ async listDecisions(opts, options) {
2174
2400
  const qs = buildListDecisionsQuery(opts);
2175
2401
  const path = qs ? `/api/v1/decisions?${qs}` : '/api/v1/decisions';
2176
2402
  // Hand-roll the request so we can branch on 429 BEFORE
@@ -2181,7 +2407,7 @@ class AxonFlow {
2181
2407
  method: 'GET',
2182
2408
  headers,
2183
2409
  signal: AbortSignal.timeout(this.config.timeout),
2184
- });
2410
+ }, options?.userToken);
2185
2411
  if (response.status === 429) {
2186
2412
  const text = await response.text();
2187
2413
  try {
@@ -2207,11 +2433,112 @@ class AxonFlow {
2207
2433
  }
2208
2434
  const body = (await response.json());
2209
2435
  const rows = body?.decisions ?? [];
2436
+ // An empty page under ReadScope.None is the fail-closed shape, not a
2437
+ // finding: the platform returned zero rows because it resolved no identity
2438
+ // to scope on, so the page says nothing about what exists. Guarded on
2439
+ // emptiness as well as on the scope, so a non-empty page is never turned
2440
+ // into an error whatever the header says — and only `none` refuses, because
2441
+ // an own-rows read that legitimately found nothing is a real answer.
2442
+ const scopeError = (0, read_identity_1.refuseVacuousScopedPage)(response, 'decisions', rows.length);
2443
+ if (scopeError)
2444
+ throw scopeError;
2210
2445
  return rows.map(parseDecisionSummary);
2211
2446
  }
2212
2447
  // ------------------------------------------------------------------ //
2213
2448
  // Decision Mode PEP: decide -> fulfill -> forward (ADR-056, #2563) //
2214
2449
  // ------------------------------------------------------------------ //
2450
+ // ------------------------------------------------------------------ //
2451
+ // AuthZEN-native authorization (ADR-065) //
2452
+ // ------------------------------------------------------------------ //
2453
+ /**
2454
+ * The transport `src/authzen.ts` runs its envelopes through.
2455
+ *
2456
+ * It is this client's own authenticated HTTP path — same credentials, same
2457
+ * `X-Axonflow-Client` attribution, same heartbeat gate — with the status left
2458
+ * uninterpreted, because on this route a 4xx body is a typed refusal document
2459
+ * rather than an error string.
2460
+ */
2461
+ //
2462
+ // A PROTOTYPE METHOD, not a class-field arrow. As a field it was an own
2463
+ // enumerable property holding the parent's lexical `this`, so `asUser`'s
2464
+ // Object.assign copied it and every `evaluate()` on a derived client sent the
2465
+ // PARENT's identity — from construction, not merely after some ordering. A
2466
+ // prototype method has no captured `this`; it binds at the call site, which is
2467
+ // the derived client. See `asUser` and
2468
+ // `a fresh instance has no own-property functions`.
2469
+ async sendAuthZEN(path, body, extraHeaders) {
2470
+ const response = await this._fetch(`${this.config.endpoint}${path}`, {
2471
+ method: 'POST',
2472
+ headers: { ...this.buildAuthHeaders(), ...extraHeaders },
2473
+ body: JSON.stringify(body),
2474
+ signal: AbortSignal.timeout(this.config.timeout),
2475
+ });
2476
+ return { status: response.status, body: await response.text() };
2477
+ }
2478
+ /**
2479
+ * Ask whether one subject may perform one action on one resource.
2480
+ *
2481
+ * The AuthZEN-native surface (`POST /api/v1/access/evaluation`). New
2482
+ * integrations should be written against this rather than
2483
+ * {@link AxonFlow.decide}: at v11 the engine behind it becomes the ADR-065
2484
+ * Policy Decision Point with no wire change, so an integration written here
2485
+ * migrates once instead of twice.
2486
+ *
2487
+ * @example
2488
+ * ```typescript
2489
+ * const decision = await axonflow.evaluate({
2490
+ * subject: { type: 'gateway', id: 'llm-gateway-01' },
2491
+ * action: { name: 'llm.completion' },
2492
+ * resource: { type: 'llm', id: 'llm' },
2493
+ * context: { args: { query: userPrompt } },
2494
+ * });
2495
+ * if (!decision.allowed) throw new Error(`blocked: ${decision.state}`);
2496
+ * ```
2497
+ *
2498
+ * @throws AuthZENRefusal when the request was NOT evaluated. This is not a
2499
+ * denial — `pointer` names the member to fix, and only `retryable` is worth
2500
+ * sending again.
2501
+ * @throws AuthZENProtocolError when the server answered 200 with a body this
2502
+ * build cannot safely act on.
2503
+ * @throws AuthenticationError on 401 — the gateway refused the credentials
2504
+ * before the route ran.
2505
+ */
2506
+ async evaluate(request) {
2507
+ // Bound at the CALL SITE, so the transport belongs to whichever client
2508
+ // is calling — including one derived by `asUser`. Passing the method
2509
+ // unbound would lose `this` entirely; capturing it in a field would
2510
+ // reintroduce the parent-bound copy this method exists to avoid.
2511
+ return (0, authzen_1.evaluateEnvelope)(this.sendAuthZEN.bind(this), (0, authzen_1.buildEnvelope)(request));
2512
+ }
2513
+ /**
2514
+ * Ask whether ONE operation is permitted against several preconditions.
2515
+ *
2516
+ * It returns ONE decision, not one per entry. The entries of a bulk request
2517
+ * are preconditions of a single operation — moving a ticket must be
2518
+ * authorized against the destination project as well as against the ticket —
2519
+ * so they combine to the least permissive outcome: one denied entry denies
2520
+ * the operation. An API returning a list would invite a caller to act on the
2521
+ * entry it liked.
2522
+ *
2523
+ * Any member an entry omits is inherited from the envelope's shared base, so
2524
+ * the common case is a shared subject and action with one resource per entry.
2525
+ *
2526
+ * @example
2527
+ * ```typescript
2528
+ * const decision = await axonflow.evaluateAll({
2529
+ * subject: { type: 'gateway', id: 'llm-gateway-01' },
2530
+ * action: { name: 'tool.call' },
2531
+ * context: { args: { query: userPrompt } },
2532
+ * evaluations: [
2533
+ * { resource: { type: 'tool', id: 'jira/move_issue' } },
2534
+ * { resource: { type: 'tool', id: 'jira/update_project' } },
2535
+ * ],
2536
+ * });
2537
+ * ```
2538
+ */
2539
+ async evaluateAll(bulk) {
2540
+ return (0, authzen_1.evaluateEnvelope)(this.sendAuthZEN.bind(this), (0, authzen_1.buildEnvelope)(undefined, bulk));
2541
+ }
2215
2542
  /**
2216
2543
  * Ask the PDP for a verdict on a request (`POST /api/v1/decide`).
2217
2544
  *
@@ -2462,7 +2789,17 @@ class AxonFlow {
2462
2789
  if (this.config.debug) {
2463
2790
  (0, helpers_1.debugLog)('Searching audit logs', { limit, offset });
2464
2791
  }
2465
- const response = await this.orchestratorRequest('POST', '/api/v1/audit/search', body);
2792
+ // The audit reads are in the same role-scoped family as decisions
2793
+ // (platform/orchestrator applyReadScopeHeader), so they inherit the same
2794
+ // rule: an empty page under scope `none` could not have contained a row,
2795
+ // and reporting it as data is the vacuous read this SDK now refuses. The
2796
+ // count is taken inside orchestratorRequest, which sees BOTH shapes the
2797
+ // platform sends — a bare array or the wrapped envelope — so the rule
2798
+ // cannot hold on only whichever branch the server happened to take.
2799
+ const response = await this.orchestratorRequest('POST', '/api/v1/audit/search', body, {
2800
+ resource: 'audit entries',
2801
+ pageKey: 'entries',
2802
+ });
2466
2803
  // Handle both array and wrapped response formats
2467
2804
  if (Array.isArray(response)) {
2468
2805
  const entries = response.map(e => this.parseAuditLogEntry(e));
@@ -2516,7 +2853,11 @@ class AxonFlow {
2516
2853
  (0, helpers_1.debugLog)('Getting audit logs for tenant', { tenantId, limit, offset });
2517
2854
  }
2518
2855
  const path = `/api/v1/audit/tenant/${encodeURIComponent(tenantId)}?limit=${limit}&offset=${offset}`;
2519
- const response = await this.orchestratorRequest('GET', path);
2856
+ // Same rule, same family as searchAuditLogs.
2857
+ const response = await this.orchestratorRequest('GET', path, undefined, {
2858
+ resource: 'audit entries',
2859
+ pageKey: 'entries',
2860
+ });
2520
2861
  // Handle both array and wrapped response formats
2521
2862
  if (Array.isArray(response)) {
2522
2863
  const entries = response.map(e => this.parseAuditLogEntry(e));
@@ -3737,7 +4078,7 @@ class AxonFlow {
3737
4078
  /**
3738
4079
  * Generic HTTP request helper for APIs (routes through single endpoint per ADR-026)
3739
4080
  */
3740
- async orchestratorRequest(method, path, body) {
4081
+ async orchestratorRequest(method, path, body, scoped) {
3741
4082
  const url = `${this.config.endpoint}${path}`;
3742
4083
  const headers = this.buildAuthHeaders();
3743
4084
  const options = {
@@ -3748,9 +4089,24 @@ class AxonFlow {
3748
4089
  if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
3749
4090
  options.body = JSON.stringify(body);
3750
4091
  }
3751
- const response = await this._fetch(url, options);
4092
+ const response = await this._fetch(url, options, scoped?.userToken);
3752
4093
  if (!response.ok) {
3753
4094
  const errorText = await response.text();
4095
+ // A scoped miss reports WHY it missed. Only 404 is interpreted: the scope
4096
+ // header is stamped before the handler writes its status, so it also
4097
+ // rides a 500 from further down the handler, and explaining a server
4098
+ // fault as a scoping outcome would be exactly the confidently-wrong
4099
+ // diagnosis this type exists to prevent.
4100
+ if (scoped && response.status === 404) {
4101
+ const scopeError = (0, read_identity_1.readScopeErrorFor)({
4102
+ resource: scoped.resource,
4103
+ identifier: scoped.identifier,
4104
+ scope: (0, read_identity_1.readScopeOf)(response),
4105
+ statusCode: response.status,
4106
+ });
4107
+ if (scopeError)
4108
+ throw scopeError;
4109
+ }
3754
4110
  if (response.status === 401 || response.status === 403) {
3755
4111
  throw new errors_1.AuthenticationError(`Request failed: ${errorText}`);
3756
4112
  }
@@ -3763,7 +4119,18 @@ class AxonFlow {
3763
4119
  if (response.status === 204) {
3764
4120
  return undefined;
3765
4121
  }
3766
- return response.json();
4122
+ const parsed = (await response.json());
4123
+ if (scoped?.pageKey !== undefined) {
4124
+ const rows = Array.isArray(parsed)
4125
+ ? parsed.length
4126
+ : Array.isArray(parsed?.[scoped.pageKey])
4127
+ ? parsed[scoped.pageKey].length
4128
+ : 0;
4129
+ const scopeError = (0, read_identity_1.refuseVacuousScopedPage)(response, scoped.resource, rows);
4130
+ if (scopeError)
4131
+ throw scopeError;
4132
+ }
4133
+ return parsed;
3767
4134
  }
3768
4135
  // Note: getPortalUrl() was removed in v2.0.0 (ADR-026 Single Entry Point).
3769
4136
  // All routes now go through the single Agent endpoint (this.config.endpoint).