@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/esm/client.js
CHANGED
|
@@ -1,9 +1,51 @@
|
|
|
1
1
|
import { VERSION } from './version.js';
|
|
2
2
|
import { maybeSendHeartbeat, flushHeartbeat } from './heartbeat.js';
|
|
3
3
|
import { sendTelemetryPingNow } from './telemetry.js';
|
|
4
|
+
import { buildEnvelope, evaluateEnvelope } from './authzen.js';
|
|
4
5
|
import { AuthenticationError, APIError, PolicyViolationError, ConfigurationError, ConnectorError, PlanExecutionError, RateLimitError, VersionConflictError, IdempotencyKeyMismatchError, ObligationNotFulfillableError, } from './errors.js';
|
|
5
6
|
import { CONTENT_TYPE_TEXT, DECIDE_PATH, OBLIGATION_REDACT_PII, PHASE_REQUEST, REQUEST_REDACTION_PATH, VERDICT_ALLOW, endpointPathMatches, stripUndefined, } from './pep.js';
|
|
6
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
|
+
}
|
|
7
49
|
/**
|
|
8
50
|
* Extract a typed IdempotencyKeyMismatchError from an APIError with HTTP 409 and
|
|
9
51
|
* `error.code === "IDEMPOTENCY_KEY_MISMATCH"` in the response body. Returns null if
|
|
@@ -98,6 +140,14 @@ export class AxonFlow {
|
|
|
98
140
|
// mode-based default-suppression rule are gone.
|
|
99
141
|
this.interceptors = [];
|
|
100
142
|
this.sessionCookie = null;
|
|
143
|
+
/**
|
|
144
|
+
* Identity of the client that owns this heartbeat promise.
|
|
145
|
+
*
|
|
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}.
|
|
149
|
+
*/
|
|
150
|
+
this.heartbeatRoot = this;
|
|
101
151
|
// Configuration validation
|
|
102
152
|
if (config.clientSecret && !config.clientId) {
|
|
103
153
|
throw new ConfigurationError('clientSecret requires clientId to be set. ' +
|
|
@@ -118,12 +168,49 @@ export class AxonFlow {
|
|
|
118
168
|
// always pass an explicit endpoint. The previous default
|
|
119
169
|
// (staging-eu.getaxonflow.com) was decommissioned 2026-04-09.
|
|
120
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
|
+
}
|
|
121
207
|
// Credentials check: OAuth2-style (clientId/clientSecret)
|
|
122
208
|
const hasCredentials = !!(config.clientId && config.clientSecret);
|
|
123
209
|
// Set configuration
|
|
124
210
|
this.config = {
|
|
125
211
|
clientId: config.clientId,
|
|
126
212
|
clientSecret: config.clientSecret,
|
|
213
|
+
userToken: config.userToken,
|
|
127
214
|
endpoint,
|
|
128
215
|
mode: config.mode ?? 'production',
|
|
129
216
|
tenant: config.tenant ?? '',
|
|
@@ -135,10 +222,6 @@ export class AxonFlow {
|
|
|
135
222
|
maxAttempts: config.retry?.maxAttempts ?? 3,
|
|
136
223
|
delay: config.retry?.delay ?? 1000,
|
|
137
224
|
},
|
|
138
|
-
cache: {
|
|
139
|
-
enabled: config.cache?.enabled !== false,
|
|
140
|
-
ttl: config.cache?.ttl ?? 60000,
|
|
141
|
-
},
|
|
142
225
|
};
|
|
143
226
|
// Interceptors removed in v3.0.0 (deprecated wrapOpenAIClient/wrapAnthropicClient)
|
|
144
227
|
this.interceptors = [];
|
|
@@ -151,21 +234,38 @@ export class AxonFlow {
|
|
|
151
234
|
authMethod,
|
|
152
235
|
});
|
|
153
236
|
}
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
});
|
|
169
269
|
}
|
|
170
270
|
/**
|
|
171
271
|
* Single hook invoked at the start of every public HTTP request path
|
|
@@ -189,6 +289,25 @@ export class AxonFlow {
|
|
|
189
289
|
endpoint: this.config.endpoint,
|
|
190
290
|
debug: this.config.debug,
|
|
191
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
|
+
}
|
|
192
311
|
}
|
|
193
312
|
/**
|
|
194
313
|
* Single HTTP wrapper used by every public-API request path. Schedules
|
|
@@ -199,9 +318,71 @@ export class AxonFlow {
|
|
|
199
318
|
* itself (sendTelemetryPingNow / detectPlatformVersion). Those use raw
|
|
200
319
|
* `fetch` to avoid recursive heartbeat triggering.
|
|
201
320
|
*/
|
|
202
|
-
async _fetch(input, init) {
|
|
321
|
+
async _fetch(input, init, identity) {
|
|
203
322
|
void this._preRequestHook();
|
|
204
|
-
|
|
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`);
|
|
205
386
|
}
|
|
206
387
|
/**
|
|
207
388
|
* Get authentication headers based on configured credentials.
|
|
@@ -2133,11 +2314,56 @@ export class AxonFlow {
|
|
|
2133
2314
|
* }
|
|
2134
2315
|
* ```
|
|
2135
2316
|
*/
|
|
2136
|
-
|
|
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) {
|
|
2137
2363
|
if (!decisionId) {
|
|
2138
2364
|
throw new Error('decisionId is required');
|
|
2139
2365
|
}
|
|
2140
|
-
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 });
|
|
2141
2367
|
return this.parseDecisionExplanation(response);
|
|
2142
2368
|
}
|
|
2143
2369
|
/**
|
|
@@ -2166,7 +2392,7 @@ export class AxonFlow {
|
|
|
2166
2392
|
* }
|
|
2167
2393
|
* ```
|
|
2168
2394
|
*/
|
|
2169
|
-
async listDecisions(opts) {
|
|
2395
|
+
async listDecisions(opts, options) {
|
|
2170
2396
|
const qs = buildListDecisionsQuery(opts);
|
|
2171
2397
|
const path = qs ? `/api/v1/decisions?${qs}` : '/api/v1/decisions';
|
|
2172
2398
|
// Hand-roll the request so we can branch on 429 BEFORE
|
|
@@ -2177,7 +2403,7 @@ export class AxonFlow {
|
|
|
2177
2403
|
method: 'GET',
|
|
2178
2404
|
headers,
|
|
2179
2405
|
signal: AbortSignal.timeout(this.config.timeout),
|
|
2180
|
-
});
|
|
2406
|
+
}, options?.userToken);
|
|
2181
2407
|
if (response.status === 429) {
|
|
2182
2408
|
const text = await response.text();
|
|
2183
2409
|
try {
|
|
@@ -2203,11 +2429,112 @@ export class AxonFlow {
|
|
|
2203
2429
|
}
|
|
2204
2430
|
const body = (await response.json());
|
|
2205
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;
|
|
2206
2441
|
return rows.map(parseDecisionSummary);
|
|
2207
2442
|
}
|
|
2208
2443
|
// ------------------------------------------------------------------ //
|
|
2209
2444
|
// Decision Mode PEP: decide -> fulfill -> forward (ADR-056, #2563) //
|
|
2210
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
|
+
}
|
|
2474
|
+
/**
|
|
2475
|
+
* Ask whether one subject may perform one action on one resource.
|
|
2476
|
+
*
|
|
2477
|
+
* The AuthZEN-native surface (`POST /api/v1/access/evaluation`). New
|
|
2478
|
+
* integrations should be written against this rather than
|
|
2479
|
+
* {@link AxonFlow.decide}: at v11 the engine behind it becomes the ADR-065
|
|
2480
|
+
* Policy Decision Point with no wire change, so an integration written here
|
|
2481
|
+
* migrates once instead of twice.
|
|
2482
|
+
*
|
|
2483
|
+
* @example
|
|
2484
|
+
* ```typescript
|
|
2485
|
+
* const decision = await axonflow.evaluate({
|
|
2486
|
+
* subject: { type: 'gateway', id: 'llm-gateway-01' },
|
|
2487
|
+
* action: { name: 'llm.completion' },
|
|
2488
|
+
* resource: { type: 'llm', id: 'llm' },
|
|
2489
|
+
* context: { args: { query: userPrompt } },
|
|
2490
|
+
* });
|
|
2491
|
+
* if (!decision.allowed) throw new Error(`blocked: ${decision.state}`);
|
|
2492
|
+
* ```
|
|
2493
|
+
*
|
|
2494
|
+
* @throws AuthZENRefusal when the request was NOT evaluated. This is not a
|
|
2495
|
+
* denial — `pointer` names the member to fix, and only `retryable` is worth
|
|
2496
|
+
* sending again.
|
|
2497
|
+
* @throws AuthZENProtocolError when the server answered 200 with a body this
|
|
2498
|
+
* build cannot safely act on.
|
|
2499
|
+
* @throws AuthenticationError on 401 — the gateway refused the credentials
|
|
2500
|
+
* before the route ran.
|
|
2501
|
+
*/
|
|
2502
|
+
async evaluate(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));
|
|
2508
|
+
}
|
|
2509
|
+
/**
|
|
2510
|
+
* Ask whether ONE operation is permitted against several preconditions.
|
|
2511
|
+
*
|
|
2512
|
+
* It returns ONE decision, not one per entry. The entries of a bulk request
|
|
2513
|
+
* are preconditions of a single operation — moving a ticket must be
|
|
2514
|
+
* authorized against the destination project as well as against the ticket —
|
|
2515
|
+
* so they combine to the least permissive outcome: one denied entry denies
|
|
2516
|
+
* the operation. An API returning a list would invite a caller to act on the
|
|
2517
|
+
* entry it liked.
|
|
2518
|
+
*
|
|
2519
|
+
* Any member an entry omits is inherited from the envelope's shared base, so
|
|
2520
|
+
* the common case is a shared subject and action with one resource per entry.
|
|
2521
|
+
*
|
|
2522
|
+
* @example
|
|
2523
|
+
* ```typescript
|
|
2524
|
+
* const decision = await axonflow.evaluateAll({
|
|
2525
|
+
* subject: { type: 'gateway', id: 'llm-gateway-01' },
|
|
2526
|
+
* action: { name: 'tool.call' },
|
|
2527
|
+
* context: { args: { query: userPrompt } },
|
|
2528
|
+
* evaluations: [
|
|
2529
|
+
* { resource: { type: 'tool', id: 'jira/move_issue' } },
|
|
2530
|
+
* { resource: { type: 'tool', id: 'jira/update_project' } },
|
|
2531
|
+
* ],
|
|
2532
|
+
* });
|
|
2533
|
+
* ```
|
|
2534
|
+
*/
|
|
2535
|
+
async evaluateAll(bulk) {
|
|
2536
|
+
return evaluateEnvelope(this.sendAuthZEN.bind(this), buildEnvelope(undefined, bulk));
|
|
2537
|
+
}
|
|
2211
2538
|
/**
|
|
2212
2539
|
* Ask the PDP for a verdict on a request (`POST /api/v1/decide`).
|
|
2213
2540
|
*
|
|
@@ -2458,7 +2785,17 @@ export class AxonFlow {
|
|
|
2458
2785
|
if (this.config.debug) {
|
|
2459
2786
|
debugLog('Searching audit logs', { limit, offset });
|
|
2460
2787
|
}
|
|
2461
|
-
|
|
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
|
+
});
|
|
2462
2799
|
// Handle both array and wrapped response formats
|
|
2463
2800
|
if (Array.isArray(response)) {
|
|
2464
2801
|
const entries = response.map(e => this.parseAuditLogEntry(e));
|
|
@@ -2512,7 +2849,11 @@ export class AxonFlow {
|
|
|
2512
2849
|
debugLog('Getting audit logs for tenant', { tenantId, limit, offset });
|
|
2513
2850
|
}
|
|
2514
2851
|
const path = `/api/v1/audit/tenant/${encodeURIComponent(tenantId)}?limit=${limit}&offset=${offset}`;
|
|
2515
|
-
|
|
2852
|
+
// Same rule, same family as searchAuditLogs.
|
|
2853
|
+
const response = await this.orchestratorRequest('GET', path, undefined, {
|
|
2854
|
+
resource: 'audit entries',
|
|
2855
|
+
pageKey: 'entries',
|
|
2856
|
+
});
|
|
2516
2857
|
// Handle both array and wrapped response formats
|
|
2517
2858
|
if (Array.isArray(response)) {
|
|
2518
2859
|
const entries = response.map(e => this.parseAuditLogEntry(e));
|
|
@@ -3733,7 +4074,7 @@ export class AxonFlow {
|
|
|
3733
4074
|
/**
|
|
3734
4075
|
* Generic HTTP request helper for APIs (routes through single endpoint per ADR-026)
|
|
3735
4076
|
*/
|
|
3736
|
-
async orchestratorRequest(method, path, body) {
|
|
4077
|
+
async orchestratorRequest(method, path, body, scoped) {
|
|
3737
4078
|
const url = `${this.config.endpoint}${path}`;
|
|
3738
4079
|
const headers = this.buildAuthHeaders();
|
|
3739
4080
|
const options = {
|
|
@@ -3744,9 +4085,24 @@ export class AxonFlow {
|
|
|
3744
4085
|
if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
|
|
3745
4086
|
options.body = JSON.stringify(body);
|
|
3746
4087
|
}
|
|
3747
|
-
const response = await this._fetch(url, options);
|
|
4088
|
+
const response = await this._fetch(url, options, scoped?.userToken);
|
|
3748
4089
|
if (!response.ok) {
|
|
3749
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
|
+
}
|
|
3750
4106
|
if (response.status === 401 || response.status === 403) {
|
|
3751
4107
|
throw new AuthenticationError(`Request failed: ${errorText}`);
|
|
3752
4108
|
}
|
|
@@ -3759,7 +4115,18 @@ export class AxonFlow {
|
|
|
3759
4115
|
if (response.status === 204) {
|
|
3760
4116
|
return undefined;
|
|
3761
4117
|
}
|
|
3762
|
-
|
|
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;
|
|
3763
4130
|
}
|
|
3764
4131
|
// Note: getPortalUrl() was removed in v2.0.0 (ADR-026 Single Entry Point).
|
|
3765
4132
|
// All routes now go through the single Agent endpoint (this.config.endpoint).
|