@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/CHANGELOG.md +1968 -0
- package/dist/auth-B6YZuSBr.js +303 -0
- package/dist/index.d.ts +1082 -36
- package/dist/index.js +468 -333
- package/dist/rest.d.ts +143 -11
- package/dist/rest.js +12 -12
- package/dist/serverErrorBus-BeN9pOpY.js +43 -0
- package/dist/session.d.ts +77 -16
- package/dist/session.js +62 -49
- package/package.json +3 -2
- package/dist/auth-CXMrvPyX.js +0 -90
- package/dist/serverErrorBus-C3JTqgIc.js +0 -157
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:
|
|
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
|
|
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
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* for a
|
|
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:
|
|
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:
|
|
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 {
|
|
2
|
-
import {
|
|
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) :
|
|
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 ?
|
|
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
|
|
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
|
|
133
|
-
if (_(
|
|
132
|
+
let e = await c.handler(p, h);
|
|
133
|
+
if (_(e)) return {
|
|
134
134
|
status: 200,
|
|
135
135
|
headers: f,
|
|
136
|
-
stream:
|
|
136
|
+
stream: e.stream
|
|
137
137
|
};
|
|
138
|
-
let n = await o.runPromise(d(
|
|
139
|
-
return g && y && await
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
297
|
-
* null on any failure (malformed, signature mismatch, expired
|
|
298
|
-
* throws — callers shouldn't have to
|
|
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
|
-
* `
|
|
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) =>
|
|
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
|