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