@axonflow/sdk 9.1.0 → 9.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +238 -22
  2. package/dist/cjs/adapters/langgraph.d.ts.map +1 -1
  3. package/dist/cjs/adapters/langgraph.js +20 -0
  4. package/dist/cjs/adapters/langgraph.js.map +1 -1
  5. package/dist/cjs/authzen.d.ts +353 -0
  6. package/dist/cjs/authzen.d.ts.map +1 -0
  7. package/dist/cjs/authzen.js +901 -0
  8. package/dist/cjs/authzen.js.map +1 -0
  9. package/dist/cjs/client.d.ts +135 -9
  10. package/dist/cjs/client.d.ts.map +1 -1
  11. package/dist/cjs/client.js +397 -30
  12. package/dist/cjs/client.js.map +1 -1
  13. package/dist/cjs/heartbeat.d.ts +55 -4
  14. package/dist/cjs/heartbeat.d.ts.map +1 -1
  15. package/dist/cjs/heartbeat.js +86 -7
  16. package/dist/cjs/heartbeat.js.map +1 -1
  17. package/dist/cjs/index.d.ts +6 -0
  18. package/dist/cjs/index.d.ts.map +1 -1
  19. package/dist/cjs/index.js +55 -1
  20. package/dist/cjs/index.js.map +1 -1
  21. package/dist/cjs/read-identity.d.ts +267 -0
  22. package/dist/cjs/read-identity.d.ts.map +1 -0
  23. package/dist/cjs/read-identity.js +329 -0
  24. package/dist/cjs/read-identity.js.map +1 -0
  25. package/dist/cjs/telemetry.d.ts +176 -13
  26. package/dist/cjs/telemetry.d.ts.map +1 -1
  27. package/dist/cjs/telemetry.js +403 -37
  28. package/dist/cjs/telemetry.js.map +1 -1
  29. package/dist/cjs/types/authzen.gen.d.ts +407 -0
  30. package/dist/cjs/types/authzen.gen.d.ts.map +1 -0
  31. package/dist/cjs/types/authzen.gen.js +804 -0
  32. package/dist/cjs/types/authzen.gen.js.map +1 -0
  33. package/dist/cjs/types/config.d.ts +43 -1
  34. package/dist/cjs/types/config.d.ts.map +1 -1
  35. package/dist/cjs/types/hitl.js +1 -1
  36. package/dist/cjs/types/hitl.js.map +1 -1
  37. package/dist/cjs/version.d.ts +1 -1
  38. package/dist/cjs/version.js +1 -1
  39. package/dist/esm/adapters/langgraph.d.ts.map +1 -1
  40. package/dist/esm/adapters/langgraph.js +20 -0
  41. package/dist/esm/adapters/langgraph.js.map +1 -1
  42. package/dist/esm/authzen.d.ts +353 -0
  43. package/dist/esm/authzen.d.ts.map +1 -0
  44. package/dist/esm/authzen.js +886 -0
  45. package/dist/esm/authzen.js.map +1 -0
  46. package/dist/esm/client.d.ts +135 -9
  47. package/dist/esm/client.d.ts.map +1 -1
  48. package/dist/esm/client.js +397 -30
  49. package/dist/esm/client.js.map +1 -1
  50. package/dist/esm/heartbeat.d.ts +55 -4
  51. package/dist/esm/heartbeat.d.ts.map +1 -1
  52. package/dist/esm/heartbeat.js +85 -7
  53. package/dist/esm/heartbeat.js.map +1 -1
  54. package/dist/esm/index.d.ts +6 -0
  55. package/dist/esm/index.d.ts.map +1 -1
  56. package/dist/esm/index.js +17 -0
  57. package/dist/esm/index.js.map +1 -1
  58. package/dist/esm/read-identity.d.ts +267 -0
  59. package/dist/esm/read-identity.d.ts.map +1 -0
  60. package/dist/esm/read-identity.js +320 -0
  61. package/dist/esm/read-identity.js.map +1 -0
  62. package/dist/esm/telemetry.d.ts +176 -13
  63. package/dist/esm/telemetry.d.ts.map +1 -1
  64. package/dist/esm/telemetry.js +397 -37
  65. package/dist/esm/telemetry.js.map +1 -1
  66. package/dist/esm/types/authzen.gen.d.ts +407 -0
  67. package/dist/esm/types/authzen.gen.d.ts.map +1 -0
  68. package/dist/esm/types/authzen.gen.js +779 -0
  69. package/dist/esm/types/authzen.gen.js.map +1 -0
  70. package/dist/esm/types/config.d.ts +43 -1
  71. package/dist/esm/types/config.d.ts.map +1 -1
  72. package/dist/esm/types/hitl.js +1 -1
  73. package/dist/esm/types/hitl.js.map +1 -1
  74. package/dist/esm/version.d.ts +1 -1
  75. package/dist/esm/version.js +1 -1
  76. package/package.json +1 -1
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Read-path per-user identity and the platform's read-scope contract.
3
+ *
4
+ * Since platform #2922 the role-scoped read routes (audit / decisions /
5
+ * overrides) answer from the identity the CALLER presents, not from the tenant
6
+ * credential alone. The tenant credential in `Authorization` says which
7
+ * organization is asking; it does not say WHO. A caller that presents no
8
+ * per-user identity to an enterprise stack is not "a caller who sees
9
+ * everything" and is not "a caller who sees nothing by coincidence" — it is a
10
+ * caller the platform cannot scope, and every scoped read it makes returns
11
+ * zero rows by construction.
12
+ *
13
+ * This module carries the whole surface:
14
+ *
15
+ * - the per-user identity itself (`userToken` on the config for a client-wide
16
+ * identity, the per-call `{ userToken }` option on a read, and
17
+ * `client.asUser(token)` for a process acting on behalf of several people),
18
+ * stamped as the `X-User-Token` header from exactly ONE site — the client's
19
+ * `_fetch` wrapper, which every request goes through. There is no per-method
20
+ * header plumbing, deliberately: the platform reads the header once in its
21
+ * own proxy middleware (`platform/agent/proxy.go` `proxyAuthMiddleware`),
22
+ * not per route, so a per-method sprinkle here would be a second, drifting
23
+ * copy of a decision the platform makes in one place.
24
+ *
25
+ * - the response side of the same contract: `X-Axonflow-Read-Scope`, which the
26
+ * platform stamps on every scoped read (`platform/orchestrator/read_scope.go`
27
+ * `applyReadScopeHeader`) to say which of the three scopes the answer was
28
+ * computed under. Without it, a 404 from explain and an empty list from
29
+ * `listDecisions` are indistinguishable from "the row is not there", which is
30
+ * how a governed read comes to report a confident, vacuous nothing.
31
+ */
32
+ /**
33
+ * The request header carrying the per-user identity.
34
+ *
35
+ * This constant is the SDK's only spelling of it. The header is set in exactly
36
+ * one place (`applyReadIdentity`, called from the client's `_fetch`); if you
37
+ * find yourself setting it in a method, the method is the wrong altitude.
38
+ */
39
+ export declare const HEADER_USER_TOKEN = "X-User-Token";
40
+ /** The response header the platform stamps on scoped reads. */
41
+ export declare const HEADER_READ_SCOPE = "X-Axonflow-Read-Scope";
42
+ /**
43
+ * The scope the platform computed a role-scoped read under, taken from the
44
+ * `X-Axonflow-Read-Scope` response header.
45
+ *
46
+ * A plain string union widened with `(string & {})` rather than a closed enum,
47
+ * for one deliberate reason: a scope value a newer platform names and this
48
+ * build does not recognise must round-trip verbatim instead of being narrowed
49
+ * away or folded into a neighbour.
50
+ *
51
+ * Three named values are the platform's closed set. Two states are NOT in it
52
+ * and are deliberately distinct from each other and from the three:
53
+ *
54
+ * - `''` (ReadScope.Absent) — the response carried no such header. That is
55
+ * what a pre-#2922 platform, a non-scoped route, or a proxy that dropped the
56
+ * header looks like. It means "not stated", never "none": treating an absent
57
+ * header as a scope of `none` would turn every older stack's perfectly good
58
+ * read into a refusal.
59
+ *
60
+ * - any other non-empty string — preserved verbatim so a caller can see what it
61
+ * was, and never a trigger for a refusal: this header is the platform's
62
+ * account of a decision it has ALREADY made and applied, so an unrecognised
63
+ * value is a reporting gap on our side, not a licence to invent an outcome.
64
+ */
65
+ export type ReadScope = 'tenant' | 'own-rows' | 'none' | '' | (string & {});
66
+ export declare const ReadScope: {
67
+ /** No `X-Axonflow-Read-Scope` header at all. Distinct from `None`. */
68
+ readonly Absent: ReadScope;
69
+ /**
70
+ * Tenant-wide: a tenant-wide role (admin / owner / policy_admin), or a
71
+ * Community / Community-SaaS deployment where the whole tenant is the one
72
+ * operator.
73
+ */
74
+ readonly Tenant: ReadScope;
75
+ /**
76
+ * Narrowed to the rows attributed to the identity presented. A miss under
77
+ * this scope means "not among yours", which is NOT the same statement as
78
+ * "not there" — see `ReadScopeError`.
79
+ */
80
+ readonly OwnRows: ReadScope;
81
+ /**
82
+ * The platform RESOLVED no per-user identity and the caller holds no
83
+ * tenant-wide authority, so it returned zero rows by construction. Under this
84
+ * scope a read CANNOT have returned data, so its empty answer says nothing
85
+ * about what exists.
86
+ *
87
+ * "Resolved none" is wider than "presented none", and the difference is worth
88
+ * knowing before you go looking in the wrong place. A token that validates
89
+ * perfectly still resolves to no identity when its address is one the
90
+ * platform reserves for SHARED, non-personal identities — the whole of
91
+ * `@axonflow.local` and `@axonflow.internal`, plus the community and
92
+ * evaluator addresses. Those name a pool of callers rather than a person, and
93
+ * scoping a read to one would return the pool, so the platform deliberately
94
+ * censuses them to nothing. A per-user token minted with an address in one of
95
+ * those domains therefore reads exactly like no token at all. (Easy to hit:
96
+ * the platform's own `generate-jwt.sh` defaults to
97
+ * `demo-user@axonflow.local`.)
98
+ */
99
+ readonly None: ReadScope;
100
+ };
101
+ /**
102
+ * The scope the platform reported on `response`.
103
+ *
104
+ * Trimmed and lower-cased, for the same reason the platform's own header
105
+ * helpers are: a proxy that normalises header casing or appends whitespace must
106
+ * not silently change the answer. The cost of getting that wrong is one-sided
107
+ * and quiet — a scope spelled `None` would fall to the unrecognised branch and
108
+ * the vacuous empty page it describes would come back as data again. An
109
+ * unrecognised value is otherwise unchanged, so it still round-trips.
110
+ */
111
+ export declare function readScopeOf(response: Response | null | undefined): ReadScope;
112
+ /**
113
+ * A role-scoped read whose answer was decided by the caller's identity scope
114
+ * rather than by the data.
115
+ *
116
+ * It exists because "no rows" and "no identity" are the same bytes on the wire.
117
+ * The platform distinguishes them in the `X-Axonflow-Read-Scope` header; this
118
+ * error is that distinction made visible, so a read that could not have
119
+ * succeeded reports a cause instead of a confident nothing.
120
+ *
121
+ * Two shapes, told apart by `identityMissing`:
122
+ *
123
+ * - `ReadScope.None` — no identity was RESOLVED; the read returned zero rows by
124
+ * construction and says nothing about what exists. Remedy: present an
125
+ * identity whose address is a real person's — see `ReadScope.None` for why a
126
+ * valid token can still resolve to nothing.
127
+ * - `ReadScope.OwnRows` — an identity WAS resolved, and the row is not among
128
+ * the ones attributed to it. That does NOT mean the row exists and belongs to
129
+ * somebody else: the platform answers "not attributed to you" and "not there
130
+ * at all" with the identical 404, deliberately, so that a miss cannot be used
131
+ * to probe for another user's rows. This error therefore reports the scope,
132
+ * not a claim about what exists.
133
+ *
134
+ * The presented token is never included in the message: it is safe to log,
135
+ * which is the point of putting the diagnosis in a type rather than in a string
136
+ * the caller assembles from the credential.
137
+ */
138
+ export declare class ReadScopeError extends Error {
139
+ readonly scope: ReadScope;
140
+ readonly statusCode: number;
141
+ readonly resource: string;
142
+ readonly identifier?: string;
143
+ constructor(args: {
144
+ scope: ReadScope;
145
+ statusCode: number;
146
+ resource?: string;
147
+ identifier?: string;
148
+ });
149
+ /**
150
+ * Whether the read failed because no per-user identity was resolved, as
151
+ * opposed to one being resolved and not matching.
152
+ */
153
+ get identityMissing(): boolean;
154
+ }
155
+ /**
156
+ * The typed refusal for a scoped read that came back with nothing, or
157
+ * `undefined` when the scope does not explain the result.
158
+ *
159
+ * `undefined` for `ReadScope.Tenant` (the caller could see the whole tenant and
160
+ * it still was not there — a genuine miss), for `ReadScope.Absent` (the
161
+ * platform did not state a scope; see `ReadScope` for why absent is not none),
162
+ * and for any scope value this build does not recognise (a newer platform's;
163
+ * reporting a cause we cannot actually read would be a confident wrong
164
+ * diagnosis).
165
+ */
166
+ export declare function readScopeErrorFor(args: {
167
+ resource: string;
168
+ identifier?: string;
169
+ scope: ReadScope;
170
+ statusCode: number;
171
+ }): ReadScopeError | undefined;
172
+ /**
173
+ * The typed refusal for a scoped read that came back EMPTY under a scope that
174
+ * could not have returned a row; `undefined` in every other case.
175
+ *
176
+ * One helper rather than a check at each read, because "the page is empty and
177
+ * the scope is none" is one rule and the reads that need it decode their body
178
+ * on more than one path each. A rule copied per return site is a rule that ends
179
+ * up applied on some of them.
180
+ *
181
+ * The emptiness guard is as load-bearing as the scope guard: a non-empty page
182
+ * is never turned into an error, whatever the header says. And only
183
+ * `ReadScope.None` refuses — an own-rows or tenant-wide read that legitimately
184
+ * found nothing is a real answer, and replacing it with an error would swap one
185
+ * wrong report for another.
186
+ */
187
+ export declare function refuseVacuousScopedPage(response: Response | null | undefined, resource: string, rows: number): ReadScopeError | undefined;
188
+ /** Options every read method accepts on top of its own. */
189
+ export interface ReadIdentityOptions {
190
+ /**
191
+ * Per-user identity for THIS call only, overriding the client-wide
192
+ * `userToken`.
193
+ *
194
+ * Use it when one process acts on behalf of several people. An empty string
195
+ * is not an identity: it makes this read explicitly unidentified rather than
196
+ * falling back to the client-wide one. That distinction has to exist, because
197
+ * "unidentified" is a state the platform treats as different from every other
198
+ * (see `ReadScope.None`).
199
+ *
200
+ * For a process acting for several people across MANY methods, prefer
201
+ * `client.asUser(token)`: this option is only accepted by the read methods,
202
+ * while a derived client reaches every method with no carve-out.
203
+ */
204
+ userToken?: string;
205
+ }
206
+ /**
207
+ * Drop every credential from `headers` because the request is leaving the
208
+ * origin they were issued for.
209
+ *
210
+ * Case-insensitive on the way in: a caller may have spelled a header
211
+ * differently, and two spellings of one header is one credential that survives.
212
+ */
213
+ export declare function stripCredentialsOffOrigin(headers: Record<string, string>): void;
214
+ /**
215
+ * Stamp the per-user identity on `headers`, if there is one, for a request to
216
+ * `target` from a client configured for `endpoint`.
217
+ *
218
+ * Called from the client's `_fetch` — the one site every request goes through —
219
+ * so the identity travels on every request without any method knowing about it.
220
+ * That is on purpose and mirrors the platform: the agent reads `X-User-Token`
221
+ * once, in the middleware in front of every proxied route, and the routes
222
+ * themselves never look at it.
223
+ *
224
+ * **The header is NOT inert on the routes that are not reads.** It is validated
225
+ * on every route the agent proxies: `proxyAuthMiddleware` resolves it before
226
+ * dispatch and answers `401 invalid user token` for a present-but-INVALID one —
227
+ * on `/api/v1/plans`, `/api/v1/policies`, `/api/v1/connectors`,
228
+ * `/api/v1/process`, `/api/v1/budgets`, `/api/v1/cost`, `/api/v1/executions`
229
+ * and the rest. So a stale or rotated token does not degrade to "unscoped
230
+ * reads"; it turns `listConnectors`, `installConnector` and policy CRUD into
231
+ * 401s. Fail-closed is the right direction, but it puts the value in the same
232
+ * rotation story as `clientSecret`.
233
+ *
234
+ * Genuinely inert only on the routes the agent SERVES ITSELF — only `proxy.go`
235
+ * and `mcp_identity.go` read the header at all: `/api/request`,
236
+ * `/api/v1/decide` (whose identity comes from the request BODY's `user_token`,
237
+ * which is the whole reason the read path needed a surface of its own),
238
+ * `/api/v1/access/evaluation`, `/api/v1/static-policies/*`,
239
+ * `/api/v1/circuit-breaker/*`, `/api/v1/hitl/*`, `/api/v1/mcp/check-input`,
240
+ * `/api/v1/mcp/check-output`, `/api/v1/register`, `/api/policy/pre-check`,
241
+ * `/api/audit/llm-call` and `/health`.
242
+ *
243
+ * **It is never sent anywhere but the configured endpoint.** `target` is
244
+ * compared against `endpoint` and the header is removed when they differ. That
245
+ * guard exists because of redirects: the fetch spec strips `Authorization` on a
246
+ * cross-origin redirect, but its list is fixed and `X-User-Token` is not on it.
247
+ * Measured on Node 25: the redirect target received `authorization: undefined`
248
+ * and `x-user-token: SENTINEL`. The client follows redirects manually while an
249
+ * identity is attached, re-entering this function on each hop, so the identity
250
+ * is dropped the moment the origin changes.
251
+ *
252
+ * The token is a CREDENTIAL. It is written to the header and nowhere else: it
253
+ * is never logged, never carried in an error message, and never reaches
254
+ * telemetry — the heartbeat uses raw `fetch`, deliberately not the wrapper this
255
+ * is called from.
256
+ */
257
+ /**
258
+ * @param headers the request's header bag, MUTATED in place.
259
+ *
260
+ * A plain record rather than a `Headers` instance, deliberately: this SDK
261
+ * builds its requests with plain objects, and converting them here would change
262
+ * the shape every existing caller and test observes on `fetch`. Widening the
263
+ * blast radius of an identity fix to "every request's header representation"
264
+ * is how a small change acquires a long tail of unrelated failures.
265
+ */
266
+ export declare function applyReadIdentity(headers: Record<string, string>, target: URL, endpoint: string | undefined, token: string | undefined): void;
267
+ //# sourceMappingURL=read-identity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"read-identity.d.ts","sourceRoot":"","sources":["../../src/read-identity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,iBAAiB,CAAC;AAEhD,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,0BAA0B,CAAC;AAEzD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,UAAU,GAAG,MAAM,GAAG,EAAE,GAAG,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;AAE5E,eAAO,MAAM,SAAS;IACpB,sEAAsE;qBACxD,SAAS;IACvB;;;;OAIG;qBACiB,SAAS;IAC7B;;;;OAIG;sBACoB,SAAS;IAChC;;;;;;;;;;;;;;;;;OAiBG;mBACa,SAAS;CACjB,CAAC;AAEX;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,GAAG,SAAS,GAAG,SAAS,CAQ5E;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,qBAAa,cAAe,SAAQ,KAAK;IACvC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;gBAEjB,IAAI,EAAE;QAChB,KAAK,EAAE,SAAS,CAAC;QACjB,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB;IA6BD;;;OAGG;IACH,IAAI,eAAe,IAAI,OAAO,CAE7B;CACF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,SAAS,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,cAAc,GAAG,SAAS,CAK7B;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,QAAQ,GAAG,IAAI,GAAG,SAAS,EACrC,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,GACX,cAAc,GAAG,SAAS,CAQ5B;AAED,2DAA2D;AAC3D,MAAM,WAAW,mBAAmB;IAClC;;;;;;;;;;;;;OAaG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA4BD;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAM/E;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,MAAM,EAAE,GAAG,EACX,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,MAAM,GAAG,SAAS,GACxB,IAAI,CA0BN"}
@@ -0,0 +1,329 @@
1
+ "use strict";
2
+ /**
3
+ * Read-path per-user identity and the platform's read-scope contract.
4
+ *
5
+ * Since platform #2922 the role-scoped read routes (audit / decisions /
6
+ * overrides) answer from the identity the CALLER presents, not from the tenant
7
+ * credential alone. The tenant credential in `Authorization` says which
8
+ * organization is asking; it does not say WHO. A caller that presents no
9
+ * per-user identity to an enterprise stack is not "a caller who sees
10
+ * everything" and is not "a caller who sees nothing by coincidence" — it is a
11
+ * caller the platform cannot scope, and every scoped read it makes returns
12
+ * zero rows by construction.
13
+ *
14
+ * This module carries the whole surface:
15
+ *
16
+ * - the per-user identity itself (`userToken` on the config for a client-wide
17
+ * identity, the per-call `{ userToken }` option on a read, and
18
+ * `client.asUser(token)` for a process acting on behalf of several people),
19
+ * stamped as the `X-User-Token` header from exactly ONE site — the client's
20
+ * `_fetch` wrapper, which every request goes through. There is no per-method
21
+ * header plumbing, deliberately: the platform reads the header once in its
22
+ * own proxy middleware (`platform/agent/proxy.go` `proxyAuthMiddleware`),
23
+ * not per route, so a per-method sprinkle here would be a second, drifting
24
+ * copy of a decision the platform makes in one place.
25
+ *
26
+ * - the response side of the same contract: `X-Axonflow-Read-Scope`, which the
27
+ * platform stamps on every scoped read (`platform/orchestrator/read_scope.go`
28
+ * `applyReadScopeHeader`) to say which of the three scopes the answer was
29
+ * computed under. Without it, a 404 from explain and an empty list from
30
+ * `listDecisions` are indistinguishable from "the row is not there", which is
31
+ * how a governed read comes to report a confident, vacuous nothing.
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.ReadScopeError = exports.ReadScope = exports.HEADER_READ_SCOPE = exports.HEADER_USER_TOKEN = void 0;
35
+ exports.readScopeOf = readScopeOf;
36
+ exports.readScopeErrorFor = readScopeErrorFor;
37
+ exports.refuseVacuousScopedPage = refuseVacuousScopedPage;
38
+ exports.stripCredentialsOffOrigin = stripCredentialsOffOrigin;
39
+ exports.applyReadIdentity = applyReadIdentity;
40
+ /**
41
+ * The request header carrying the per-user identity.
42
+ *
43
+ * This constant is the SDK's only spelling of it. The header is set in exactly
44
+ * one place (`applyReadIdentity`, called from the client's `_fetch`); if you
45
+ * find yourself setting it in a method, the method is the wrong altitude.
46
+ */
47
+ exports.HEADER_USER_TOKEN = 'X-User-Token';
48
+ /** The response header the platform stamps on scoped reads. */
49
+ exports.HEADER_READ_SCOPE = 'X-Axonflow-Read-Scope';
50
+ exports.ReadScope = {
51
+ /** No `X-Axonflow-Read-Scope` header at all. Distinct from `None`. */
52
+ Absent: '',
53
+ /**
54
+ * Tenant-wide: a tenant-wide role (admin / owner / policy_admin), or a
55
+ * Community / Community-SaaS deployment where the whole tenant is the one
56
+ * operator.
57
+ */
58
+ Tenant: 'tenant',
59
+ /**
60
+ * Narrowed to the rows attributed to the identity presented. A miss under
61
+ * this scope means "not among yours", which is NOT the same statement as
62
+ * "not there" — see `ReadScopeError`.
63
+ */
64
+ OwnRows: 'own-rows',
65
+ /**
66
+ * The platform RESOLVED no per-user identity and the caller holds no
67
+ * tenant-wide authority, so it returned zero rows by construction. Under this
68
+ * scope a read CANNOT have returned data, so its empty answer says nothing
69
+ * about what exists.
70
+ *
71
+ * "Resolved none" is wider than "presented none", and the difference is worth
72
+ * knowing before you go looking in the wrong place. A token that validates
73
+ * perfectly still resolves to no identity when its address is one the
74
+ * platform reserves for SHARED, non-personal identities — the whole of
75
+ * `@axonflow.local` and `@axonflow.internal`, plus the community and
76
+ * evaluator addresses. Those name a pool of callers rather than a person, and
77
+ * scoping a read to one would return the pool, so the platform deliberately
78
+ * censuses them to nothing. A per-user token minted with an address in one of
79
+ * those domains therefore reads exactly like no token at all. (Easy to hit:
80
+ * the platform's own `generate-jwt.sh` defaults to
81
+ * `demo-user@axonflow.local`.)
82
+ */
83
+ None: 'none',
84
+ };
85
+ /**
86
+ * The scope the platform reported on `response`.
87
+ *
88
+ * Trimmed and lower-cased, for the same reason the platform's own header
89
+ * helpers are: a proxy that normalises header casing or appends whitespace must
90
+ * not silently change the answer. The cost of getting that wrong is one-sided
91
+ * and quiet — a scope spelled `None` would fall to the unrecognised branch and
92
+ * the vacuous empty page it describes would come back as data again. An
93
+ * unrecognised value is otherwise unchanged, so it still round-trips.
94
+ */
95
+ function readScopeOf(response) {
96
+ // A response with no readable header bag states no scope. That is the same
97
+ // answer as a pre-#2922 platform's, and it is the SAFE one: the alternative
98
+ // is throwing from inside a read, or — worse — inventing `none` and refusing
99
+ // a page that was perfectly good.
100
+ const headers = response?.headers;
101
+ if (!headers || typeof headers.get !== 'function')
102
+ return exports.ReadScope.Absent;
103
+ return (headers.get(exports.HEADER_READ_SCOPE) ?? '').trim().toLowerCase();
104
+ }
105
+ /**
106
+ * A role-scoped read whose answer was decided by the caller's identity scope
107
+ * rather than by the data.
108
+ *
109
+ * It exists because "no rows" and "no identity" are the same bytes on the wire.
110
+ * The platform distinguishes them in the `X-Axonflow-Read-Scope` header; this
111
+ * error is that distinction made visible, so a read that could not have
112
+ * succeeded reports a cause instead of a confident nothing.
113
+ *
114
+ * Two shapes, told apart by `identityMissing`:
115
+ *
116
+ * - `ReadScope.None` — no identity was RESOLVED; the read returned zero rows by
117
+ * construction and says nothing about what exists. Remedy: present an
118
+ * identity whose address is a real person's — see `ReadScope.None` for why a
119
+ * valid token can still resolve to nothing.
120
+ * - `ReadScope.OwnRows` — an identity WAS resolved, and the row is not among
121
+ * the ones attributed to it. That does NOT mean the row exists and belongs to
122
+ * somebody else: the platform answers "not attributed to you" and "not there
123
+ * at all" with the identical 404, deliberately, so that a miss cannot be used
124
+ * to probe for another user's rows. This error therefore reports the scope,
125
+ * not a claim about what exists.
126
+ *
127
+ * The presented token is never included in the message: it is safe to log,
128
+ * which is the point of putting the diagnosis in a type rather than in a string
129
+ * the caller assembles from the credential.
130
+ */
131
+ class ReadScopeError extends Error {
132
+ constructor(args) {
133
+ const resource = args.resource ?? 'read';
134
+ const subject = args.identifier ? `${resource} "${args.identifier}"` : resource;
135
+ const identityMissing = args.scope === exports.ReadScope.None;
136
+ super(identityMissing
137
+ ? `HTTP ${args.statusCode}: ${subject}: the platform resolved no per-user identity for ` +
138
+ `this read (${exports.HEADER_READ_SCOPE}: ${args.scope}), so it returned zero rows by ` +
139
+ `construction and the empty answer says nothing about what exists. Either no ` +
140
+ `identity was presented — set userToken on the client, pass it to this call, or use ` +
141
+ `client.asUser(...) — or the one presented carries an address the platform reserves ` +
142
+ `for shared identities (@axonflow.local, @axonflow.internal), which resolves to ` +
143
+ `nobody. (platform #2922)`
144
+ : `HTTP ${args.statusCode}: ${subject} was not found among the rows this identity can ` +
145
+ `see: the platform reports ${exports.HEADER_READ_SCOPE}: ${args.scope}, so the read was ` +
146
+ `narrowed to the identity's own rows. It is either not attributed to this identity ` +
147
+ `or not there at all — the platform answers both the same way ON PURPOSE, so that a ` +
148
+ `miss cannot be used to probe for the existence of another user's rows, and this SDK ` +
149
+ `cannot tell them apart either. A tenant-wide role (admin, owner or policy_admin) ` +
150
+ `reads the whole tenant. (platform #2922)`);
151
+ this.name = 'ReadScopeError';
152
+ this.scope = args.scope;
153
+ this.statusCode = args.statusCode;
154
+ this.resource = resource;
155
+ this.identifier = args.identifier;
156
+ Object.setPrototypeOf(this, ReadScopeError.prototype);
157
+ }
158
+ /**
159
+ * Whether the read failed because no per-user identity was resolved, as
160
+ * opposed to one being resolved and not matching.
161
+ */
162
+ get identityMissing() {
163
+ return this.scope === exports.ReadScope.None;
164
+ }
165
+ }
166
+ exports.ReadScopeError = ReadScopeError;
167
+ /**
168
+ * The typed refusal for a scoped read that came back with nothing, or
169
+ * `undefined` when the scope does not explain the result.
170
+ *
171
+ * `undefined` for `ReadScope.Tenant` (the caller could see the whole tenant and
172
+ * it still was not there — a genuine miss), for `ReadScope.Absent` (the
173
+ * platform did not state a scope; see `ReadScope` for why absent is not none),
174
+ * and for any scope value this build does not recognise (a newer platform's;
175
+ * reporting a cause we cannot actually read would be a confident wrong
176
+ * diagnosis).
177
+ */
178
+ function readScopeErrorFor(args) {
179
+ if (args.scope === exports.ReadScope.None || args.scope === exports.ReadScope.OwnRows) {
180
+ return new ReadScopeError(args);
181
+ }
182
+ return undefined;
183
+ }
184
+ /**
185
+ * The typed refusal for a scoped read that came back EMPTY under a scope that
186
+ * could not have returned a row; `undefined` in every other case.
187
+ *
188
+ * One helper rather than a check at each read, because "the page is empty and
189
+ * the scope is none" is one rule and the reads that need it decode their body
190
+ * on more than one path each. A rule copied per return site is a rule that ends
191
+ * up applied on some of them.
192
+ *
193
+ * The emptiness guard is as load-bearing as the scope guard: a non-empty page
194
+ * is never turned into an error, whatever the header says. And only
195
+ * `ReadScope.None` refuses — an own-rows or tenant-wide read that legitimately
196
+ * found nothing is a real answer, and replacing it with an error would swap one
197
+ * wrong report for another.
198
+ */
199
+ function refuseVacuousScopedPage(response, resource, rows) {
200
+ if (rows > 0)
201
+ return undefined;
202
+ if (readScopeOf(response) !== exports.ReadScope.None)
203
+ return undefined;
204
+ return new ReadScopeError({
205
+ scope: exports.ReadScope.None,
206
+ statusCode: response?.status ?? 0,
207
+ resource,
208
+ });
209
+ }
210
+ /** Whether two URLs are the same origin: scheme, host AND port. */
211
+ function sameOrigin(a, b) {
212
+ return a.origin === b.origin;
213
+ }
214
+ /**
215
+ * Every credential this SDK sends, so an off-origin hop can drop ALL of them.
216
+ *
217
+ * Not just the new one. `fetch`'s own redirect follower strips `Authorization`
218
+ * on a cross-origin hop; the moment this SDK follows redirects by hand — which
219
+ * it does whenever an identity is attached — that stripping stops happening and
220
+ * becomes this code's job. Getting it wrong makes setting `userToken` LEAK
221
+ * `clientSecret` to a host the caller never named, on a client that did not
222
+ * leak it before: a fix for one credential that exports another.
223
+ *
224
+ * `X-Client-ID` and `X-Axonflow-Client` are not secrets, but they name the
225
+ * caller to whoever receives them and there is no reason for a host the caller
226
+ * never chose to learn it.
227
+ */
228
+ const CREDENTIAL_HEADERS = [
229
+ 'authorization',
230
+ exports.HEADER_USER_TOKEN.toLowerCase(),
231
+ 'x-client-id',
232
+ 'x-axonflow-client',
233
+ ];
234
+ /**
235
+ * Drop every credential from `headers` because the request is leaving the
236
+ * origin they were issued for.
237
+ *
238
+ * Case-insensitive on the way in: a caller may have spelled a header
239
+ * differently, and two spellings of one header is one credential that survives.
240
+ */
241
+ function stripCredentialsOffOrigin(headers) {
242
+ for (const key of Object.keys(headers)) {
243
+ if (CREDENTIAL_HEADERS.includes(key.toLowerCase())) {
244
+ delete headers[key];
245
+ }
246
+ }
247
+ }
248
+ /**
249
+ * Stamp the per-user identity on `headers`, if there is one, for a request to
250
+ * `target` from a client configured for `endpoint`.
251
+ *
252
+ * Called from the client's `_fetch` — the one site every request goes through —
253
+ * so the identity travels on every request without any method knowing about it.
254
+ * That is on purpose and mirrors the platform: the agent reads `X-User-Token`
255
+ * once, in the middleware in front of every proxied route, and the routes
256
+ * themselves never look at it.
257
+ *
258
+ * **The header is NOT inert on the routes that are not reads.** It is validated
259
+ * on every route the agent proxies: `proxyAuthMiddleware` resolves it before
260
+ * dispatch and answers `401 invalid user token` for a present-but-INVALID one —
261
+ * on `/api/v1/plans`, `/api/v1/policies`, `/api/v1/connectors`,
262
+ * `/api/v1/process`, `/api/v1/budgets`, `/api/v1/cost`, `/api/v1/executions`
263
+ * and the rest. So a stale or rotated token does not degrade to "unscoped
264
+ * reads"; it turns `listConnectors`, `installConnector` and policy CRUD into
265
+ * 401s. Fail-closed is the right direction, but it puts the value in the same
266
+ * rotation story as `clientSecret`.
267
+ *
268
+ * Genuinely inert only on the routes the agent SERVES ITSELF — only `proxy.go`
269
+ * and `mcp_identity.go` read the header at all: `/api/request`,
270
+ * `/api/v1/decide` (whose identity comes from the request BODY's `user_token`,
271
+ * which is the whole reason the read path needed a surface of its own),
272
+ * `/api/v1/access/evaluation`, `/api/v1/static-policies/*`,
273
+ * `/api/v1/circuit-breaker/*`, `/api/v1/hitl/*`, `/api/v1/mcp/check-input`,
274
+ * `/api/v1/mcp/check-output`, `/api/v1/register`, `/api/policy/pre-check`,
275
+ * `/api/audit/llm-call` and `/health`.
276
+ *
277
+ * **It is never sent anywhere but the configured endpoint.** `target` is
278
+ * compared against `endpoint` and the header is removed when they differ. That
279
+ * guard exists because of redirects: the fetch spec strips `Authorization` on a
280
+ * cross-origin redirect, but its list is fixed and `X-User-Token` is not on it.
281
+ * Measured on Node 25: the redirect target received `authorization: undefined`
282
+ * and `x-user-token: SENTINEL`. The client follows redirects manually while an
283
+ * identity is attached, re-entering this function on each hop, so the identity
284
+ * is dropped the moment the origin changes.
285
+ *
286
+ * The token is a CREDENTIAL. It is written to the header and nowhere else: it
287
+ * is never logged, never carried in an error message, and never reaches
288
+ * telemetry — the heartbeat uses raw `fetch`, deliberately not the wrapper this
289
+ * is called from.
290
+ */
291
+ /**
292
+ * @param headers the request's header bag, MUTATED in place.
293
+ *
294
+ * A plain record rather than a `Headers` instance, deliberately: this SDK
295
+ * builds its requests with plain objects, and converting them here would change
296
+ * the shape every existing caller and test observes on `fetch`. Widening the
297
+ * blast radius of an identity fix to "every request's header representation"
298
+ * is how a small change acquires a long tail of unrelated failures.
299
+ */
300
+ function applyReadIdentity(headers, target, endpoint, token) {
301
+ // Case-insensitively, because a caller may have spelled it differently and
302
+ // two spellings of one header is two identities.
303
+ for (const key of Object.keys(headers)) {
304
+ if (key.toLowerCase() === exports.HEADER_USER_TOKEN.toLowerCase())
305
+ delete headers[key];
306
+ }
307
+ const trimmed = (token ?? '').trim();
308
+ if (!trimmed) {
309
+ // Never send an empty header. To the platform a present-but-empty
310
+ // X-User-Token is still an absent one, but sending it advertises an
311
+ // identity mechanism the caller is not using, and it is one refactor away
312
+ // from a present-but-invalid token, which is a hard 401. The delete above
313
+ // also makes an explicit per-call clearing actually clear.
314
+ return;
315
+ }
316
+ if (endpoint) {
317
+ try {
318
+ if (!sameOrigin(target, new URL(endpoint)))
319
+ return;
320
+ }
321
+ catch {
322
+ // An unparseable configured endpoint is not a licence to send the
323
+ // credential anyway.
324
+ return;
325
+ }
326
+ }
327
+ headers[exports.HEADER_USER_TOKEN] = trimmed;
328
+ }
329
+ //# sourceMappingURL=read-identity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"read-identity.js","sourceRoot":"","sources":["../../src/read-identity.ts"],"names":[],"mappings":"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"}