@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.
- package/README.md +238 -22
- package/dist/cjs/adapters/langgraph.d.ts.map +1 -1
- package/dist/cjs/adapters/langgraph.js +20 -0
- package/dist/cjs/adapters/langgraph.js.map +1 -1
- package/dist/cjs/authzen.d.ts +353 -0
- package/dist/cjs/authzen.d.ts.map +1 -0
- package/dist/cjs/authzen.js +901 -0
- package/dist/cjs/authzen.js.map +1 -0
- package/dist/cjs/client.d.ts +135 -9
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/client.js +397 -30
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/heartbeat.d.ts +55 -4
- package/dist/cjs/heartbeat.d.ts.map +1 -1
- package/dist/cjs/heartbeat.js +86 -7
- package/dist/cjs/heartbeat.js.map +1 -1
- package/dist/cjs/index.d.ts +6 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +55 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/read-identity.d.ts +267 -0
- package/dist/cjs/read-identity.d.ts.map +1 -0
- package/dist/cjs/read-identity.js +329 -0
- package/dist/cjs/read-identity.js.map +1 -0
- package/dist/cjs/telemetry.d.ts +176 -13
- package/dist/cjs/telemetry.d.ts.map +1 -1
- package/dist/cjs/telemetry.js +403 -37
- package/dist/cjs/telemetry.js.map +1 -1
- package/dist/cjs/types/authzen.gen.d.ts +407 -0
- package/dist/cjs/types/authzen.gen.d.ts.map +1 -0
- package/dist/cjs/types/authzen.gen.js +804 -0
- package/dist/cjs/types/authzen.gen.js.map +1 -0
- package/dist/cjs/types/config.d.ts +43 -1
- package/dist/cjs/types/config.d.ts.map +1 -1
- package/dist/cjs/types/hitl.js +1 -1
- package/dist/cjs/types/hitl.js.map +1 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/adapters/langgraph.d.ts.map +1 -1
- package/dist/esm/adapters/langgraph.js +20 -0
- package/dist/esm/adapters/langgraph.js.map +1 -1
- package/dist/esm/authzen.d.ts +353 -0
- package/dist/esm/authzen.d.ts.map +1 -0
- package/dist/esm/authzen.js +886 -0
- package/dist/esm/authzen.js.map +1 -0
- package/dist/esm/client.d.ts +135 -9
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +397 -30
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/heartbeat.d.ts +55 -4
- package/dist/esm/heartbeat.d.ts.map +1 -1
- package/dist/esm/heartbeat.js +85 -7
- package/dist/esm/heartbeat.js.map +1 -1
- package/dist/esm/index.d.ts +6 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +17 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/read-identity.d.ts +267 -0
- package/dist/esm/read-identity.d.ts.map +1 -0
- package/dist/esm/read-identity.js +320 -0
- package/dist/esm/read-identity.js.map +1 -0
- package/dist/esm/telemetry.d.ts +176 -13
- package/dist/esm/telemetry.d.ts.map +1 -1
- package/dist/esm/telemetry.js +397 -37
- package/dist/esm/telemetry.js.map +1 -1
- package/dist/esm/types/authzen.gen.d.ts +407 -0
- package/dist/esm/types/authzen.gen.d.ts.map +1 -0
- package/dist/esm/types/authzen.gen.js +779 -0
- package/dist/esm/types/authzen.gen.js.map +1 -0
- package/dist/esm/types/config.d.ts +43 -1
- package/dist/esm/types/config.d.ts.map +1 -1
- package/dist/esm/types/hitl.js +1 -1
- package/dist/esm/types/hitl.js.map +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/dist/cjs/client.js
CHANGED
|
@@ -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
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|