@voltro/protocol 0.33.0 → 0.35.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/dist/rest.d.ts CHANGED
@@ -24,11 +24,19 @@ declare interface ActionProcedureDescriptor<Name extends string, Input extends S
24
24
  readonly target: TargetSpec | ReadonlyArray<TargetSpec> | undefined;
25
25
  /** Declarative authorization guard(s) — enforced before the executor runs,
26
26
  * failing with a typed `ScopeError`. Absent → no framework-level authz. */
27
- readonly guards: Guards | undefined;
27
+ readonly guards: DeclaredAccess | undefined;
28
+ /** The declared reason this procedure needs NO authorization check —
29
+ * `openAccess: '<why>'`. Mutually exclusive with `guards`; together they are
30
+ * the only two shapes `security.defaultDeny` accepts. */
31
+ readonly openAccess: string | undefined;
28
32
  /** Opt this action into a public REST endpoint (innovation/11). */
29
33
  readonly publicApi: PublicApiSpec | undefined;
30
34
  /** Opt this action into the auto-synthesized agent toolset (innovation/07). */
31
35
  readonly exposeAsTool: ExposeAsTool | undefined;
36
+ /** Require a SECOND human to approve before this action takes effect. The gate
37
+ * runs in the dispatch spine after `guards:` and BEFORE the executor's
38
+ * external I/O — the only point at which nothing has happened yet. */
39
+ readonly requiresApproval: AnyApprovalPolicy | undefined;
32
40
  /** True when the procedure is kept OFF the wire — no client-group entry and no
33
41
  * route in dev or serve. See `internal` on the definer's options. */
34
42
  /**
@@ -56,6 +64,13 @@ declare interface ActionProcedureDescriptor<Name extends string, Input extends S
56
64
  readonly internal: boolean | undefined;
57
65
  }
58
66
 
67
+ /** The erased policy a descriptor carries (input generic dropped). */
68
+ declare interface AnyApprovalPolicy {
69
+ readonly approvers: Guards;
70
+ readonly expiresIn?: string;
71
+ readonly reason?: string;
72
+ }
73
+
59
74
  /** A guard is either a scope check or a relationship check. */
60
75
  declare type AnyGuardSpec<Input = unknown> = GuardSpec<Input> | PolicyGuardSpec<Input>;
61
76
 
@@ -66,6 +81,18 @@ declare type AnyGuardSpec<Input = unknown> = GuardSpec<Input> | PolicyGuardSpec<
66
81
  */
67
82
  export declare const collectPublicApiRoutes: (entries: ReadonlyArray<PublicApiEntry>) => ReadonlyArray<RestRouteDescriptor<Record<string, unknown>, unknown>>;
68
83
 
84
+ /**
85
+ * What a descriptor CARRIES: the author's guards, or the erased form of their
86
+ * `openAccess:` decision — never both.
87
+ *
88
+ * The option a user writes is still `Guards` (an `OpenAccessSpec` is not
89
+ * something to hand-write into `guards:`; there is one spelling for the
90
+ * decision and it is the `openAccess:` field). The DESCRIPTOR type is wider
91
+ * because that is where the normalised decision lands, and because every
92
+ * enforcement path reads the descriptor's array and nothing else.
93
+ */
94
+ declare type DeclaredAccess<Input = unknown> = ReadonlyArray<AnyGuardSpec<Input> | OpenAccessSpec>;
95
+
69
96
  /**
70
97
  * Identity helper that fixes the descriptor's types (like `defineAction` /
71
98
  * `definePlugin`). Returns its argument unchanged at runtime; exists for
@@ -94,7 +121,8 @@ export declare const derivePublicPath: (tag: string, spec: PublicApiSpec) => str
94
121
  * side effect when the method isn't its own — so probing the group in order and
95
122
  * taking the FIRST non-405 result yields the route that owns the request's
96
123
  * method. This is what lets the standard GET + POST on one resource path
97
- * coexist: the serve pipeline mounts ONE dispatcher per path (the underlying
124
+ * coexist: the runtime's rpc server (`rpcServer.ts`, started by both boot
125
+ * paths) mounts ONE dispatcher per path (the underlying
98
126
  * router rejects two mounts on the same `(method, path)`). A single-route group
99
127
  * returns that route's result directly (its own 405 included); when no route in
100
128
  * the group owns the method, the last 405 stands.
@@ -131,13 +159,32 @@ declare interface GuardSpec<Input = unknown> {
131
159
  * scope); `'any'` = OR (hold at least one). Ignored for a single scope. */
132
160
  readonly mode?: 'all' | 'any';
133
161
  /**
134
- * PURE `input → resource id` extractor for a row/resource-scoped guard.
135
- * Browser-safe (no DB, no server import) — exactly like `target.identify`.
136
- * The framework passes the extracted id to a resource-aware scope resolver
137
- * (a future ReBAC / `accessPolicy()` resolver) so the check can be scoped to
138
- * THAT resource. With only the default (subject-global) resolver installed
139
- * the id is advisory and the guard checks the subject's global scopes. Omit
140
- * for a plain subject-scope guard.
162
+ * PURE `input → resource id` extractor. Browser-safe (no DB, no server
163
+ * import) — exactly like `target.identify`. Omit for a plain subject-scope
164
+ * guard.
165
+ *
166
+ * **On a `GuardSpec` this id is ADVISORY.** A scope guard answers "what may
167
+ * this subject do at all", against the subject's global scope set; the id is
168
+ * carried for logging and for a future subject-scope resolver that narrows by
169
+ * resource. It does not, on its own, make the check per-resource.
170
+ *
171
+ * **If your authority is per-resource, you want {@link PolicyGuardSpec}, not
172
+ * this field** — `guards: [{ action, resourceType, resource }]`, backed by
173
+ * `defineResourcePolicy` + a tuple source you register. That is built, wired
174
+ * on both boot paths, fail-closed without a resolver, and documented under
175
+ * *Authentication → Authorization*. An app whose relationships already live
176
+ * in its own tables (a `teamMembers` row, say) registers its own tuple source
177
+ * rather than copying data across; see `policyGuardResolver.ts`.
178
+ *
179
+ * That paragraph is here because its absence cost a consumer their access
180
+ * gate. This comment used to describe the resolver as "a future ReBAC /
181
+ * `accessPolicy()` resolver" — written before the ReBAC path shipped and
182
+ * never updated. They read the type, quoted the sentence, concluded there was
183
+ * "nothing in between" declaring an untruth and turning the gate off, and set
184
+ * `security: { defaultDeny: false }` on an app with 565 undecided procedures.
185
+ * The capability they needed was two fields away. A doc comment that says
186
+ * "future" about something shipped is not a small inaccuracy: it is the only
187
+ * thing a careful reader has, and it argued them out of a feature.
141
188
  */
142
189
  readonly resource?: (input: Input) => string | undefined;
143
190
  }
@@ -224,11 +271,19 @@ declare interface MutationProcedureDescriptor<Name extends string, Input extends
224
271
  /** Declarative authorization guard(s) — enforced before the transaction
225
272
  * opens, failing with a typed `ScopeError`. Absent → no framework-level
226
273
  * authz (author gates in-handler, or the mutation is unguarded). */
227
- readonly guards: Guards | undefined;
274
+ readonly guards: DeclaredAccess | undefined;
275
+ /** The declared reason this procedure needs NO authorization check —
276
+ * `openAccess: '<why>'`. Mutually exclusive with `guards`; together they are
277
+ * the only two shapes `security.defaultDeny` accepts. */
278
+ readonly openAccess: string | undefined;
228
279
  /** Opt this mutation into a public REST endpoint (innovation/11). */
229
280
  readonly publicApi: PublicApiSpec | undefined;
230
281
  /** Opt this mutation into the auto-synthesized agent toolset (innovation/07). */
231
282
  readonly exposeAsTool: ExposeAsTool | undefined;
283
+ /** Require a SECOND human to approve before this mutation takes effect. The
284
+ * gate runs in the dispatch spine after `guards:` and before the transaction
285
+ * opens; the pending intent is a durable `_voltro_approvals` row. */
286
+ readonly requiresApproval: AnyApprovalPolicy | undefined;
232
287
  /** True when the procedure is kept OFF the wire — no client-group entry and no
233
288
  * route in dev or serve. See `internal` on the definer's options. */
234
289
  /**
@@ -271,6 +326,27 @@ declare interface NestedTargetFields<Input = unknown> {
271
326
  readonly match?: ((value: unknown, input: Input) => boolean) | undefined;
272
327
  }
273
328
 
329
+ /**
330
+ * The runtime-erased form of a procedure's `openAccess:` — a DECLARED decision
331
+ * that this procedure needs no authorization check, and the reason.
332
+ *
333
+ * It is a guard entry rather than a bare descriptor field on purpose. Every
334
+ * enforcement path in the framework — `servePipeline`'s `enforceGuards`,
335
+ * `bindStream`, `bindEvent`, `@voltro/testing`'s `invoke` — is handed the
336
+ * `guards` ARRAY and nothing else. A decision that does not live in that array
337
+ * is invisible to all of them, so "guarded" and "deliberately open" would be
338
+ * distinguishable in the source and identical at the point that enforces.
339
+ *
340
+ * It always passes. The value is the WHY, and the why is the point: it is what
341
+ * a reviewer reads, what `voltro doctor` prints, and what makes an open
342
+ * procedure a decision somebody made rather than a field somebody forgot.
343
+ */
344
+ declare interface OpenAccessSpec {
345
+ /** Why this procedure is callable without an authorization check. Non-empty
346
+ * by construction — `defineQuery` & co. refuse an empty reason. */
347
+ readonly open: string;
348
+ }
349
+
274
350
  /** A public raw-HTTP route a plugin serves on the framework listener. */
275
351
  declare interface PluginHttpRoute {
276
352
  /** HTTP method, or `'*'` for any (the handler decides). */
@@ -279,6 +355,42 @@ declare interface PluginHttpRoute {
279
355
  * any sub-path (`/_voltro/storage/abc123`). */
280
356
  readonly path: string;
281
357
  readonly handle: (req: PluginHttpRouteRequest) => Promise<PluginHttpRouteResult>;
358
+ /**
359
+ * Opt this route's path OUT of the listener's cross-site origin check.
360
+ *
361
+ * Every state-changing request (anything but GET/HEAD/OPTIONS) is
362
+ * origin-checked by default, because the default assumption has to be that a
363
+ * route can be reached with the browser's ambient session cookie — and a
364
+ * route that can is CSRF-reachable. Declaring `'exempt'` is a claim that this
365
+ * route CANNOT be: its caller must present something a browser will not
366
+ * attach cross-site.
367
+ *
368
+ * The test to apply, and it is the only one:
369
+ *
370
+ * > If an attacker's page makes a browser send this request with the
371
+ * > victim's cookies attached, does anything happen?
372
+ *
373
+ * If the answer is "no, the request still needs a signature / a bearer token
374
+ * / a signed ticket the attacker does not have", the route is exempt.
375
+ * Otherwise it is not, and no amount of "but it is behind the dashboard"
376
+ * makes it so.
377
+ *
378
+ * The first-party exemptions and why each qualifies:
379
+ * - `@voltro/plugin-sso-saml` `/saml` — the IdP delivers the assertion as a
380
+ * genuine cross-site browser form POST; authority is the signed
381
+ * SAMLResponse, not the cookie.
382
+ * - `@voltro/plugin-storage` `/…/upload` + `/…/upload/resumable` — a signed
383
+ * upload ticket in the query string, and the route ships its own CORS
384
+ * allowlist because a cross-origin upload is the point.
385
+ * - `@voltro/plugin-billing` `/billing/webhook` — an HMAC-verified provider
386
+ * callback.
387
+ * - `@voltro/plugin-scim` `/scim/v2` — bearer-only, refuses to mount without
388
+ * a token.
389
+ *
390
+ * Granularity is the PATH PREFIX the route mounts, not the sub-path its
391
+ * handler branches on: exempting `/saml` exempts `POST /saml/anything`.
392
+ */
393
+ readonly originGuard?: 'exempt';
282
394
  }
283
395
 
284
396
  declare interface PluginHttpRouteRequest {
@@ -341,6 +453,22 @@ declare interface PluginHttpRouteRequest {
341
453
  * this store reads them.
342
454
  */
343
455
  readonly store?: DataStore;
456
+ /**
457
+ * The client address, resolved through the app's `security.trustedProxies`
458
+ * policy — the SAME value the rate limiter, the geo-block and every audit row
459
+ * use (`resolveClientAddress`). Use this, never `headers['x-forwarded-for']`.
460
+ *
461
+ * `x-forwarded-for` is a request header: any client can write it. Reading it
462
+ * raw means a caller picks the IP that lands in your `sessions.ipAddress`
463
+ * column, which is the one field a breach investigation leans on. Three
464
+ * first-party routes did exactly that until SEC-8 was extended down to this
465
+ * surface. The resolution here ignores the header entirely unless a trusted
466
+ * proxy is declared, and then believes only the hops that are one.
467
+ *
468
+ * `undefined` when the socket address is unavailable (a unix socket, an
469
+ * in-process test harness that constructs the request by hand).
470
+ */
471
+ readonly remoteAddr?: string | undefined;
344
472
  }
345
473
 
346
474
  /**
@@ -554,7 +682,11 @@ declare interface QueryProcedureDescriptor<Name extends string, Input extends Sc
554
682
  readonly cache: QueryCacheConfig | undefined;
555
683
  /** Declarative authorization guard(s) — enforced before the executor runs,
556
684
  * failing with a typed `ScopeError`. Absent → no framework-level authz. */
557
- readonly guards: Guards | undefined;
685
+ readonly guards: DeclaredAccess | undefined;
686
+ /** The declared reason this procedure needs NO authorization check —
687
+ * `openAccess: '<why>'`. Mutually exclusive with `guards`; together they are
688
+ * the only two shapes `security.defaultDeny` accepts. */
689
+ readonly openAccess: string | undefined;
558
690
  /** Opt this query into a public REST endpoint (innovation/11). */
559
691
  readonly publicApi: PublicApiSpec | undefined;
560
692
  /** Opt this query into the auto-synthesized agent toolset (innovation/07). */
package/dist/rest.js CHANGED
@@ -1,5 +1,5 @@
1
- import { a as e, i as t, o as n, r, t as i } from "./serverErrorBus-C3JTqgIc.js";
2
- import { s as a } from "./auth-CXMrvPyX.js";
1
+ import { u as e } from "./auth-B6YZuSBr.js";
2
+ import { a as t, i as n, o as r, r as i, t as a } from "./serverErrorBus-BeN9pOpY.js";
3
3
  import { Effect as o, Schema as s } from "effect";
4
4
  //#region src/publicApi.ts
5
5
  var c = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), l = (e, t) => t.path ?? `/${t.version ?? "v1"}/${e.replace(/\./g, "/")}`, u = (e, t, n) => {
@@ -110,7 +110,7 @@ var c = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), l = (e, t) => t.
110
110
  }, f);
111
111
  p = t.value;
112
112
  }
113
- let m = l.resolveSubject ? await l.resolveSubject(s.headers) : a(s.headers["x-tenant"] ?? null), h = {
113
+ let m = l.resolveSubject ? await l.resolveSubject(s.headers) : e(s.headers["x-tenant"] ?? null), h = {
114
114
  subject: m,
115
115
  headers: s.headers,
116
116
  store: l.store
@@ -119,9 +119,9 @@ var c = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), l = (e, t) => t.
119
119
  let t = await e(h);
120
120
  if (t) return v(t.status, { error: t.message }, f);
121
121
  }
122
- let g = l.idempotency, y = g && C.has(c.method) ? s.headers[g.header.toLowerCase()] : void 0, b = g && y ? n(m.tenantId, c.method, c.path) : "";
122
+ let g = l.idempotency, y = g && C.has(c.method) ? s.headers[g.header.toLowerCase()] : void 0, b = g && y ? r(m.tenantId, c.method, c.path) : "";
123
123
  if (g && y) {
124
- let e = await r(g.store, b, y, g.ttlMs, Date.now());
124
+ let e = await i(g.store, b, y, g.ttlMs, Date.now());
125
125
  if (e.kind === "replay") return v(e.response.status, e.response.body, {
126
126
  ...f,
127
127
  "Idempotency-Replayed": "true"
@@ -129,23 +129,23 @@ var c = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), l = (e, t) => t.
129
129
  if (e.kind === "conflict") return v(409, { error: "A request with this Idempotency-Key is already being processed" }, f);
130
130
  }
131
131
  try {
132
- let t = await c.handler(p, h);
133
- if (_(t)) return {
132
+ let e = await c.handler(p, h);
133
+ if (_(e)) return {
134
134
  status: 200,
135
135
  headers: f,
136
- stream: t.stream
136
+ stream: e.stream
137
137
  };
138
- let n = await o.runPromise(d(t));
139
- return g && y && await e(g.store, b, y, {
138
+ let n = await o.runPromise(d(e));
139
+ return g && y && await t(g.store, b, y, {
140
140
  status: 200,
141
141
  body: n
142
142
  }, Date.now()), v(200, n, f);
143
143
  } catch (e) {
144
- if (g && y && await t(g.store, b, y), typeof e == "object" && e && typeof e.status == "number") {
144
+ if (g && y && await n(g.store, b, y), typeof e == "object" && e && typeof e.status == "number") {
145
145
  let t = e;
146
146
  return v(t.status, { error: t.message ?? "Error" }, f);
147
147
  }
148
- return i({
148
+ return a({
149
149
  error: e,
150
150
  source: "rest",
151
151
  name: `${c.method} ${c.path}`,
@@ -0,0 +1,43 @@
1
+ //#region src/idempotency.ts
2
+ var e = (e, t, n) => `${e ?? "(global)"}|${t.toUpperCase()}|${n}`, t = (e, t, n) => `${e ?? "(global)"}|${t ?? "(anon)"}|WS|${n}`, n = async (e, t, n, r, i) => {
3
+ let a = await e.claim(t, n, i, r);
4
+ return a === "claimed" ? { kind: "fresh" } : a.status === "completed" && a.response ? {
5
+ kind: "replay",
6
+ response: a.response
7
+ } : { kind: "conflict" };
8
+ }, r = (e, t, n, r, i) => e.complete(t, n, r, i), i = (e, t, n) => e.release(t, n), a = () => {
9
+ let e = /* @__PURE__ */ new Map(), t = (e, t) => `${e}\u0000${t}`;
10
+ return {
11
+ get: async (n, r) => e.get(t(n, r)) ?? null,
12
+ claim: async (n, r, i, a) => {
13
+ let o = e.get(t(n, r));
14
+ return o && i - o.createdAt < a ? o : (e.set(t(n, r), {
15
+ scope: n,
16
+ key: r,
17
+ status: "in_flight",
18
+ response: null,
19
+ createdAt: i
20
+ }), "claimed");
21
+ },
22
+ complete: async (n, r, i, a) => {
23
+ e.set(t(n, r), {
24
+ scope: n,
25
+ key: r,
26
+ status: "completed",
27
+ response: i,
28
+ createdAt: a
29
+ });
30
+ },
31
+ release: async (n, r) => {
32
+ e.delete(t(n, r));
33
+ }
34
+ };
35
+ }, o = /* @__PURE__ */ new Set(), s = (e) => {
36
+ for (let t of o) try {
37
+ t(e);
38
+ } catch {}
39
+ }, c = (e) => (o.add(e), () => {
40
+ o.delete(e);
41
+ });
42
+ //#endregion
43
+ export { r as a, t as c, i, c as n, e as o, n as r, a as s, s as t };
package/dist/session.d.ts CHANGED
@@ -74,6 +74,13 @@ export declare const MIN_SESSION_SECRET_LENGTH: 32;
74
74
  */
75
75
  export declare const readCookie: (header: string | undefined, name: string) => string | undefined;
76
76
 
77
+ /**
78
+ * Reset the once-per-process warning latch. Tests only — the latch is what
79
+ * makes the diagnostic readable in production and unobservable in a second
80
+ * test case.
81
+ */
82
+ export declare const resetSessionSecretWarningLatch: () => void;
83
+
77
84
  /**
78
85
  * The same resolution as `resolveSessionSecrets`, but yielding `undefined`
79
86
  * instead of throwing when `VOLTRO_SESSION_SECRET` is unset.
@@ -118,9 +125,22 @@ export declare const resolveSessionSecrets: () => SessionSecrets;
118
125
  */
119
126
  export declare const SESSION_COOKIE_NAME: string;
120
127
 
128
+ /**
129
+ * The session payload format this build mints and accepts.
130
+ *
131
+ * Bumped when the payload's MEANING changes, not when a field is added — a
132
+ * reader that ignores an unknown field is fine; a reader that would take a
133
+ * field to mean something it no longer means is not. `2` dropped `scopes`:
134
+ * a `1` payload asserted authority, and honouring that assertion is the defect.
135
+ */
136
+ export declare const SESSION_PAYLOAD_VERSION: 2;
137
+
121
138
  /**
122
139
  * The verified expiry of the session cookie in `headers`, in unix SECONDS, or
123
- * `undefined` when there is no session or it does not verify.
140
+ * `undefined` when there is no session cookie, it does not verify, or no
141
+ * session secret is configured (that last case is logged once, loudly — see
142
+ * the branch below; a security bound that is absent must not be absent
143
+ * SILENTLY).
124
144
  *
125
145
  * It VERIFIES rather than decoding. An unverified read would be worse than
126
146
  * nothing here: the value bounds how long a subscription may live, so a client
@@ -138,33 +158,32 @@ export declare const sessionExpiryFromHeaders: (headers: Record<string, string |
138
158
  export declare type SessionPayload = typeof SessionPayload_2.Type;
139
159
 
140
160
  declare const SessionPayload_2: Schema.Struct<{
161
+ /** Payload format version. Required and pinned — see the header. */
162
+ v: Schema.Literal<[2]>;
163
+ /** WHO, never WHAT-THEY-MAY-DO. `SubjectIdentity` has no `scopes` field. */
141
164
  subject: Schema.Union<[Schema.Struct<{
142
- type: Schema.Literal<["user"]>;
143
165
  id: typeof Schema.String;
166
+ type: Schema.Literal<["user"]>;
144
167
  tenantId: typeof Schema.String;
145
- scopes: Schema.optional<Schema.Array$<typeof Schema.String>>;
146
168
  metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
147
169
  }>, Schema.Struct<{
148
- type: Schema.Literal<["apiKey"]>;
149
170
  id: typeof Schema.String;
171
+ type: Schema.Literal<["apiKey"]>;
150
172
  tenantId: typeof Schema.String;
151
- scopes: Schema.optional<Schema.Array$<typeof Schema.String>>;
152
173
  metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
153
174
  }>, Schema.Struct<{
154
- type: Schema.Literal<["serviceAccount"]>;
155
175
  id: typeof Schema.String;
176
+ type: Schema.Literal<["serviceAccount"]>;
156
177
  tenantId: typeof Schema.String;
157
- scopes: Schema.optional<Schema.Array$<typeof Schema.String>>;
158
178
  metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
159
179
  }>, Schema.Struct<{
160
180
  type: Schema.Literal<["anonymous"]>;
161
181
  id: typeof Schema.Null;
162
182
  tenantId: Schema.NullOr<typeof Schema.String>;
163
183
  }>, Schema.Struct<{
164
- type: Schema.Literal<["system"]>;
165
184
  id: typeof Schema.String;
185
+ type: Schema.Literal<["system"]>;
166
186
  tenantId: typeof Schema.Null;
167
- scopes: Schema.optional<Schema.Array$<typeof Schema.String>>;
168
187
  metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
169
188
  }>]>;
170
189
  /** Unix seconds when this session expires. Verify rejects past expiry. */
@@ -226,8 +245,17 @@ export declare interface SignOptions {
226
245
  * forge a new one without the secret. `HttpOnly` blocks JS access for
227
246
  * defence-in-depth; the verify side doesn't care whether the client
228
247
  * could read it.
248
+ *
249
+ * **It REFUSES a subject carrying `scopes`.** A cookie cannot carry authority
250
+ * (see the header), and the two alternatives to refusing are both worse than a
251
+ * thrown error at the one call site per app that mints a session. Signing them
252
+ * would reintroduce exactly the defect. Dropping them silently would change
253
+ * what a caller may do with no error, no log line, and no diff — an
254
+ * authorization change disguised as a no-op, discovered later as "permissions
255
+ * randomly stopped working". So: throw, name the subject, name the seam that
256
+ * replaces it.
229
257
  */
230
- export declare const signSession: (subject: Subject, secret: string | KeyedSecret, options?: SignOptions) => string;
258
+ export declare const signSession: (subject: Subject | SubjectIdentity, secret: string | KeyedSecret, options?: SignOptions) => string;
231
259
 
232
260
  declare const Subject: Schema.Union<[Schema.Struct<{
233
261
  type: Schema.Literal<["user"]>;
@@ -261,6 +289,34 @@ declare const Subject: Schema.Union<[Schema.Struct<{
261
289
 
262
290
  declare type Subject = typeof Subject.Type;
263
291
 
292
+ declare const SubjectIdentity: Schema.Union<[Schema.Struct<{
293
+ id: typeof Schema.String;
294
+ type: Schema.Literal<["user"]>;
295
+ tenantId: typeof Schema.String;
296
+ metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
297
+ }>, Schema.Struct<{
298
+ id: typeof Schema.String;
299
+ type: Schema.Literal<["apiKey"]>;
300
+ tenantId: typeof Schema.String;
301
+ metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
302
+ }>, Schema.Struct<{
303
+ id: typeof Schema.String;
304
+ type: Schema.Literal<["serviceAccount"]>;
305
+ tenantId: typeof Schema.String;
306
+ metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
307
+ }>, Schema.Struct<{
308
+ type: Schema.Literal<["anonymous"]>;
309
+ id: typeof Schema.Null;
310
+ tenantId: Schema.NullOr<typeof Schema.String>;
311
+ }>, Schema.Struct<{
312
+ id: typeof Schema.String;
313
+ type: Schema.Literal<["system"]>;
314
+ tenantId: typeof Schema.Null;
315
+ metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
316
+ }>]>;
317
+
318
+ declare type SubjectIdentity = typeof SubjectIdentity.Type;
319
+
264
320
  /**
265
321
  * Constant-time string equality via `timingSafeEqual`. Returns false fast
266
322
  * on a length mismatch (the lengths aren't secret); only equal-length inputs
@@ -280,7 +336,11 @@ export declare interface VerifyOptions {
280
336
  * the sliding-window threshold — the caller re-issues the cookie so an
281
337
  * active user never gets logged out mid-session. */
282
338
  export declare interface VerifyResult {
283
- readonly subject: Subject;
339
+ /** IDENTITY, not a Subject. The narrow type is the guarantee stated at the
340
+ * type level: nothing downstream can read authority out of a cookie,
341
+ * because the value it gets back has no field for it. It is still assignable
342
+ * to `Subject` wherever one is wanted — a Subject with no scopes. */
343
+ readonly subject: SubjectIdentity;
284
344
  readonly renew: boolean;
285
345
  /** The key id that verified the value (current or previous). */
286
346
  readonly kid: string;
@@ -293,18 +353,19 @@ export declare interface VerifyResult {
293
353
  }
294
354
 
295
355
  /**
296
- * Verify a session cookie value. Returns the decoded Subject on success,
297
- * null on any failure (malformed, signature mismatch, expired). Never
298
- * throws — callers shouldn't have to wrap this in try/catch.
356
+ * Verify a session cookie value. Returns the decoded identity on success,
357
+ * null on any failure (malformed, signature mismatch, expired, or a payload
358
+ * from a superseded format version). Never throws — callers shouldn't have to
359
+ * wrap this in try/catch.
299
360
  *
300
361
  * Uses `timingSafeEqual` for the signature comparison so attackers
301
362
  * can't recover bytes one-at-a-time via response-time analysis.
302
363
  *
303
364
  * This is the single-secret entry point and stays a pure
304
- * `Subject | null`. Use `verifySessionKeyed` for the multi-key +
365
+ * `SubjectIdentity | null`. Use `verifySessionKeyed` for the multi-key +
305
366
  * sliding-window result shape.
306
367
  */
307
- export declare const verifySession: (cookieValue: string, secret: string) => Subject | null;
368
+ export declare const verifySession: (cookieValue: string, secret: string) => SubjectIdentity | null;
308
369
 
309
370
  /**
310
371
  * Multi-key + sliding-window verify. Tries `secrets.current` first, then