@voltro/protocol 0.33.0 → 0.34.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 +1801 -0
- package/dist/auth-B6YZuSBr.js +303 -0
- package/dist/index.d.ts +1004 -26
- package/dist/index.js +267 -148
- package/dist/rest.d.ts +115 -3
- 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
|
|
@@ -224,11 +251,19 @@ declare interface MutationProcedureDescriptor<Name extends string, Input extends
|
|
|
224
251
|
/** Declarative authorization guard(s) — enforced before the transaction
|
|
225
252
|
* opens, failing with a typed `ScopeError`. Absent → no framework-level
|
|
226
253
|
* authz (author gates in-handler, or the mutation is unguarded). */
|
|
227
|
-
readonly guards:
|
|
254
|
+
readonly guards: DeclaredAccess | undefined;
|
|
255
|
+
/** The declared reason this procedure needs NO authorization check —
|
|
256
|
+
* `openAccess: '<why>'`. Mutually exclusive with `guards`; together they are
|
|
257
|
+
* the only two shapes `security.defaultDeny` accepts. */
|
|
258
|
+
readonly openAccess: string | undefined;
|
|
228
259
|
/** Opt this mutation into a public REST endpoint (innovation/11). */
|
|
229
260
|
readonly publicApi: PublicApiSpec | undefined;
|
|
230
261
|
/** Opt this mutation into the auto-synthesized agent toolset (innovation/07). */
|
|
231
262
|
readonly exposeAsTool: ExposeAsTool | undefined;
|
|
263
|
+
/** Require a SECOND human to approve before this mutation takes effect. The
|
|
264
|
+
* gate runs in the dispatch spine after `guards:` and before the transaction
|
|
265
|
+
* opens; the pending intent is a durable `_voltro_approvals` row. */
|
|
266
|
+
readonly requiresApproval: AnyApprovalPolicy | undefined;
|
|
232
267
|
/** True when the procedure is kept OFF the wire — no client-group entry and no
|
|
233
268
|
* route in dev or serve. See `internal` on the definer's options. */
|
|
234
269
|
/**
|
|
@@ -271,6 +306,27 @@ declare interface NestedTargetFields<Input = unknown> {
|
|
|
271
306
|
readonly match?: ((value: unknown, input: Input) => boolean) | undefined;
|
|
272
307
|
}
|
|
273
308
|
|
|
309
|
+
/**
|
|
310
|
+
* The runtime-erased form of a procedure's `openAccess:` — a DECLARED decision
|
|
311
|
+
* that this procedure needs no authorization check, and the reason.
|
|
312
|
+
*
|
|
313
|
+
* It is a guard entry rather than a bare descriptor field on purpose. Every
|
|
314
|
+
* enforcement path in the framework — `servePipeline`'s `enforceGuards`,
|
|
315
|
+
* `bindStream`, `bindEvent`, `@voltro/testing`'s `invoke` — is handed the
|
|
316
|
+
* `guards` ARRAY and nothing else. A decision that does not live in that array
|
|
317
|
+
* is invisible to all of them, so "guarded" and "deliberately open" would be
|
|
318
|
+
* distinguishable in the source and identical at the point that enforces.
|
|
319
|
+
*
|
|
320
|
+
* It always passes. The value is the WHY, and the why is the point: it is what
|
|
321
|
+
* a reviewer reads, what `voltro doctor` prints, and what makes an open
|
|
322
|
+
* procedure a decision somebody made rather than a field somebody forgot.
|
|
323
|
+
*/
|
|
324
|
+
declare interface OpenAccessSpec {
|
|
325
|
+
/** Why this procedure is callable without an authorization check. Non-empty
|
|
326
|
+
* by construction — `defineQuery` & co. refuse an empty reason. */
|
|
327
|
+
readonly open: string;
|
|
328
|
+
}
|
|
329
|
+
|
|
274
330
|
/** A public raw-HTTP route a plugin serves on the framework listener. */
|
|
275
331
|
declare interface PluginHttpRoute {
|
|
276
332
|
/** HTTP method, or `'*'` for any (the handler decides). */
|
|
@@ -279,6 +335,42 @@ declare interface PluginHttpRoute {
|
|
|
279
335
|
* any sub-path (`/_voltro/storage/abc123`). */
|
|
280
336
|
readonly path: string;
|
|
281
337
|
readonly handle: (req: PluginHttpRouteRequest) => Promise<PluginHttpRouteResult>;
|
|
338
|
+
/**
|
|
339
|
+
* Opt this route's path OUT of the listener's cross-site origin check.
|
|
340
|
+
*
|
|
341
|
+
* Every state-changing request (anything but GET/HEAD/OPTIONS) is
|
|
342
|
+
* origin-checked by default, because the default assumption has to be that a
|
|
343
|
+
* route can be reached with the browser's ambient session cookie — and a
|
|
344
|
+
* route that can is CSRF-reachable. Declaring `'exempt'` is a claim that this
|
|
345
|
+
* route CANNOT be: its caller must present something a browser will not
|
|
346
|
+
* attach cross-site.
|
|
347
|
+
*
|
|
348
|
+
* The test to apply, and it is the only one:
|
|
349
|
+
*
|
|
350
|
+
* > If an attacker's page makes a browser send this request with the
|
|
351
|
+
* > victim's cookies attached, does anything happen?
|
|
352
|
+
*
|
|
353
|
+
* If the answer is "no, the request still needs a signature / a bearer token
|
|
354
|
+
* / a signed ticket the attacker does not have", the route is exempt.
|
|
355
|
+
* Otherwise it is not, and no amount of "but it is behind the dashboard"
|
|
356
|
+
* makes it so.
|
|
357
|
+
*
|
|
358
|
+
* The first-party exemptions and why each qualifies:
|
|
359
|
+
* - `@voltro/plugin-sso-saml` `/saml` — the IdP delivers the assertion as a
|
|
360
|
+
* genuine cross-site browser form POST; authority is the signed
|
|
361
|
+
* SAMLResponse, not the cookie.
|
|
362
|
+
* - `@voltro/plugin-storage` `/…/upload` + `/…/upload/resumable` — a signed
|
|
363
|
+
* upload ticket in the query string, and the route ships its own CORS
|
|
364
|
+
* allowlist because a cross-origin upload is the point.
|
|
365
|
+
* - `@voltro/plugin-billing` `/billing/webhook` — an HMAC-verified provider
|
|
366
|
+
* callback.
|
|
367
|
+
* - `@voltro/plugin-scim` `/scim/v2` — bearer-only, refuses to mount without
|
|
368
|
+
* a token.
|
|
369
|
+
*
|
|
370
|
+
* Granularity is the PATH PREFIX the route mounts, not the sub-path its
|
|
371
|
+
* handler branches on: exempting `/saml` exempts `POST /saml/anything`.
|
|
372
|
+
*/
|
|
373
|
+
readonly originGuard?: 'exempt';
|
|
282
374
|
}
|
|
283
375
|
|
|
284
376
|
declare interface PluginHttpRouteRequest {
|
|
@@ -341,6 +433,22 @@ declare interface PluginHttpRouteRequest {
|
|
|
341
433
|
* this store reads them.
|
|
342
434
|
*/
|
|
343
435
|
readonly store?: DataStore;
|
|
436
|
+
/**
|
|
437
|
+
* The client address, resolved through the app's `security.trustedProxies`
|
|
438
|
+
* policy — the SAME value the rate limiter, the geo-block and every audit row
|
|
439
|
+
* use (`resolveClientAddress`). Use this, never `headers['x-forwarded-for']`.
|
|
440
|
+
*
|
|
441
|
+
* `x-forwarded-for` is a request header: any client can write it. Reading it
|
|
442
|
+
* raw means a caller picks the IP that lands in your `sessions.ipAddress`
|
|
443
|
+
* column, which is the one field a breach investigation leans on. Three
|
|
444
|
+
* first-party routes did exactly that until SEC-8 was extended down to this
|
|
445
|
+
* surface. The resolution here ignores the header entirely unless a trusted
|
|
446
|
+
* proxy is declared, and then believes only the hops that are one.
|
|
447
|
+
*
|
|
448
|
+
* `undefined` when the socket address is unavailable (a unix socket, an
|
|
449
|
+
* in-process test harness that constructs the request by hand).
|
|
450
|
+
*/
|
|
451
|
+
readonly remoteAddr?: string | undefined;
|
|
344
452
|
}
|
|
345
453
|
|
|
346
454
|
/**
|
|
@@ -554,7 +662,11 @@ declare interface QueryProcedureDescriptor<Name extends string, Input extends Sc
|
|
|
554
662
|
readonly cache: QueryCacheConfig | undefined;
|
|
555
663
|
/** Declarative authorization guard(s) — enforced before the executor runs,
|
|
556
664
|
* failing with a typed `ScopeError`. Absent → no framework-level authz. */
|
|
557
|
-
readonly guards:
|
|
665
|
+
readonly guards: DeclaredAccess | undefined;
|
|
666
|
+
/** The declared reason this procedure needs NO authorization check —
|
|
667
|
+
* `openAccess: '<why>'`. Mutually exclusive with `guards`; together they are
|
|
668
|
+
* the only two shapes `security.defaultDeny` accepts. */
|
|
669
|
+
readonly openAccess: string | undefined;
|
|
558
670
|
/** Opt this query into a public REST endpoint (innovation/11). */
|
|
559
671
|
readonly publicApi: PublicApiSpec | undefined;
|
|
560
672
|
/** 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
|
package/dist/session.js
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Schema as
|
|
3
|
-
import { createHmac as
|
|
1
|
+
import { U as e, s as t, v as n } from "./auth-B6YZuSBr.js";
|
|
2
|
+
import { Schema as r } from "effect";
|
|
3
|
+
import { createHmac as i, timingSafeEqual as a } from "node:crypto";
|
|
4
|
+
import { createLogger as o } from "@voltro/logger";
|
|
4
5
|
//#region src/session.ts
|
|
5
|
-
var
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
var s = o({ scope: "@voltro/protocol:session" }), c = 2, l = r.Struct({
|
|
7
|
+
v: r.Literal(2),
|
|
8
|
+
subject: t,
|
|
9
|
+
exp: r.Number,
|
|
10
|
+
iat: r.Number,
|
|
11
|
+
kid: r.optional(r.String)
|
|
12
|
+
}), u = "k0", d = "k-previous", f = () => {
|
|
11
13
|
throw Error("VOLTRO_SESSION_SECRET is not set. It signs and verifies session cookies, and the framework refuses to invent one at this point: a fixed fallback would be readable by anyone with the source, making every session forgeable. `voltro dev` mints a project-local value into .env.local on boot; for a deployment, generate one with `voltro secret generate session`.");
|
|
12
|
-
},
|
|
13
|
-
kid:
|
|
14
|
-
secret:
|
|
15
|
-
} },
|
|
14
|
+
}, p = () => process.env.VOLTRO_SESSION_SECRET ?? f(), m = () => g() ?? { current: {
|
|
15
|
+
kid: h(),
|
|
16
|
+
secret: p()
|
|
17
|
+
} }, h = () => process.env.VOLTRO_SESSION_KID ?? "k0", g = () => {
|
|
16
18
|
let e = process.env.VOLTRO_SESSION_SECRET;
|
|
17
19
|
if (e === void 0 || e === "") return;
|
|
18
20
|
let t = {
|
|
19
|
-
kid:
|
|
21
|
+
kid: h(),
|
|
20
22
|
secret: e
|
|
21
23
|
}, n = process.env.VOLTRO_SESSION_SECRET_PREVIOUS;
|
|
22
24
|
return n ? {
|
|
@@ -26,51 +28,54 @@ var i = t.Struct({
|
|
|
26
28
|
secret: n
|
|
27
29
|
}
|
|
28
30
|
} : { current: t };
|
|
29
|
-
},
|
|
31
|
+
}, _ = 32, v = (e = process.env) => {
|
|
30
32
|
let t = e.VOLTRO_SESSION_SECRET;
|
|
31
33
|
if (t === void 0 || t === "") throw Error("VOLTRO_SESSION_SECRET is not set — refusing to serve. It signs session cookies, and there is no fallback: a built-in one would be readable by anyone with the source. Generate one with `voltro secret generate session`. (`voltro dev` mints a project-local value automatically; a deployment needs its own.)");
|
|
32
34
|
if (t.length < 32) throw Error(`VOLTRO_SESSION_SECRET is only ${t.length} chars — too short for a session secret (need >= 32). This looks like a placeholder. Generate a real one with \`voltro secret generate session\`.`);
|
|
33
|
-
},
|
|
35
|
+
}, y = r.encodeSync(l), b = r.decodeUnknownSync(l), x = (e) => (typeof e == "string" ? Buffer.from(e, "utf8") : e).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""), S = (e) => {
|
|
34
36
|
let t = e.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - e.length % 4) % 4);
|
|
35
37
|
return Buffer.from(t, "base64");
|
|
36
|
-
},
|
|
38
|
+
}, C = 3600 * 24 * 7, w = .7, T = (e, t) => x(i("sha256", t).update(e).digest()), E = (e) => typeof e == "string" ? {
|
|
37
39
|
kid: "k0",
|
|
38
40
|
secret: e
|
|
39
|
-
} : e,
|
|
40
|
-
let
|
|
41
|
-
if (!
|
|
42
|
-
let
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
41
|
+
} : e, D = (t, r, i = {}) => {
|
|
42
|
+
let a = E(r);
|
|
43
|
+
if (!a.secret) throw Error("signSession: secret must be a non-empty string");
|
|
44
|
+
let o = e(t);
|
|
45
|
+
if (o.length > 0) throw Error(`signSession: refusing to mint a session cookie for a ${t.type} subject carrying ${o.length} scope(s) (${o.slice(0, 5).join(", ")}). A session cookie carries IDENTITY only — authority is resolved per request, so that removing a role takes effect on the session that already exists instead of in up to 7 days. Mint the session from the identity and move the scopes into the auth.resolveScopes resolver, which runs on every request and can be invalidated the moment a role changes.`);
|
|
46
|
+
let s = i.ttlSeconds ?? C, c = Math.floor((i.now ?? Date.now)() / 1e3), l = {
|
|
47
|
+
v: 2,
|
|
48
|
+
subject: n(t),
|
|
49
|
+
exp: c + s,
|
|
50
|
+
iat: c,
|
|
51
|
+
...typeof r == "string" ? {} : { kid: a.kid }
|
|
52
|
+
}, u = x(JSON.stringify(y(l)));
|
|
53
|
+
return `${u}.${T(u, a.secret)}`;
|
|
54
|
+
}, O = (e, t, n, r) => {
|
|
55
|
+
let o = i("sha256", n).update(e).digest(), s = S(t);
|
|
56
|
+
if (s.length !== o.length || !a(s, o)) return null;
|
|
52
57
|
try {
|
|
53
|
-
let t =
|
|
54
|
-
return n.exp < Math.floor(
|
|
58
|
+
let t = S(e).toString("utf8"), n = b(JSON.parse(t));
|
|
59
|
+
return n.exp < Math.floor(r() / 1e3) ? null : n;
|
|
55
60
|
} catch {
|
|
56
61
|
return null;
|
|
57
62
|
}
|
|
58
|
-
},
|
|
63
|
+
}, k = (e, t) => {
|
|
59
64
|
if (!e || !t) return null;
|
|
60
65
|
let n = e.indexOf(".");
|
|
61
66
|
if (n <= 0 || n === e.length - 1) return null;
|
|
62
|
-
let r =
|
|
67
|
+
let r = O(e.slice(0, n), e.slice(n + 1), t, Date.now);
|
|
63
68
|
return r ? r.subject : null;
|
|
64
|
-
},
|
|
69
|
+
}, A = (e, t, n = {}) => {
|
|
65
70
|
if (!e) return null;
|
|
66
71
|
let r = e.indexOf(".");
|
|
67
72
|
if (r <= 0 || r === e.length - 1) return null;
|
|
68
73
|
let i = e.slice(0, r), a = e.slice(r + 1), o = t.previous ? [t.current, t.previous] : [t.current], s = n.now ?? Date.now;
|
|
69
74
|
for (let e of o) {
|
|
70
75
|
if (!e.secret) continue;
|
|
71
|
-
let t =
|
|
76
|
+
let t = O(i, a, e.secret, s);
|
|
72
77
|
if (!t) continue;
|
|
73
|
-
let r = n.renewFraction ??
|
|
78
|
+
let r = n.renewFraction ?? w, o = Math.floor(s() / 1e3), c = t.exp - t.iat, l = t.iat + c * r;
|
|
74
79
|
return {
|
|
75
80
|
subject: t.subject,
|
|
76
81
|
renew: o >= l,
|
|
@@ -80,14 +85,14 @@ var i = t.Struct({
|
|
|
80
85
|
};
|
|
81
86
|
}
|
|
82
87
|
return null;
|
|
83
|
-
},
|
|
84
|
-
let n = Buffer.from(e, "utf8"),
|
|
85
|
-
return n.length ===
|
|
86
|
-
},
|
|
88
|
+
}, j = (e, t) => {
|
|
89
|
+
let n = Buffer.from(e, "utf8"), r = Buffer.from(t, "utf8");
|
|
90
|
+
return n.length === r.length && a(n, r);
|
|
91
|
+
}, M = (e, t, n) => {
|
|
87
92
|
if (!t || t.length === 0) return n?.openWhenUnset === !0;
|
|
88
93
|
let r = e.authorization ?? e.Authorization;
|
|
89
|
-
return !r || !r.startsWith("Bearer ") ? !1 :
|
|
90
|
-
},
|
|
94
|
+
return !r || !r.startsWith("Bearer ") ? !1 : j(r.slice(7).trim(), t);
|
|
95
|
+
}, N = (e, t) => {
|
|
91
96
|
if (e) for (let n of e.split(";")) {
|
|
92
97
|
let e = n.indexOf("=");
|
|
93
98
|
if (e < 0 || n.slice(0, e).trim() !== t) continue;
|
|
@@ -99,18 +104,26 @@ var i = t.Struct({
|
|
|
99
104
|
return r;
|
|
100
105
|
}
|
|
101
106
|
}
|
|
102
|
-
},
|
|
107
|
+
}, P = (e, t, n = {}) => {
|
|
103
108
|
let r = [`${e}=${encodeURIComponent(t)}`];
|
|
104
109
|
if (r.push(`Path=${n.path ?? "/"}`), n.maxAgeSeconds !== void 0 && r.push(`Max-Age=${n.maxAgeSeconds}`), r.push("HttpOnly"), n.httpOnly === !1) {
|
|
105
110
|
let e = r.indexOf("HttpOnly");
|
|
106
111
|
e >= 0 && r.splice(e, 1);
|
|
107
112
|
}
|
|
108
113
|
return r.push(`SameSite=${n.sameSite ?? "Lax"}`), (n.secure ?? !0) && r.push("Secure"), n.domain && r.push(`Domain=${n.domain}`), r.join("; ");
|
|
109
|
-
},
|
|
110
|
-
let t =
|
|
114
|
+
}, F = process.env.VOLTRO_SESSION_COOKIE ?? "voltro:session", I = (e) => {
|
|
115
|
+
let t = N(e.cookie ?? e.Cookie, F);
|
|
111
116
|
if (!t) return;
|
|
112
|
-
let n =
|
|
113
|
-
if (n)
|
|
117
|
+
let n = g();
|
|
118
|
+
if (!n) {
|
|
119
|
+
R();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
return A(t, n)?.exp;
|
|
123
|
+
}, L = !1, R = () => {
|
|
124
|
+
L || (L = !0, s.error("a `voltro:session` cookie was presented but VOLTRO_SESSION_SECRET is not set — the session cannot be verified, so no credential-expiry bound is imposed and realtime subscriptions on this connection will not be cut off when the session expires. Set VOLTRO_SESSION_SECRET (`voltro secret generate session`); `voltro dev` mints one, `voltro serve` refuses to boot without one.", { cookieName: F }));
|
|
125
|
+
}, z = () => {
|
|
126
|
+
L = !1;
|
|
114
127
|
};
|
|
115
128
|
//#endregion
|
|
116
|
-
export {
|
|
129
|
+
export { d as DEFAULT_PREVIOUS_SESSION_KID, u as DEFAULT_SESSION_KID, _ as MIN_SESSION_SECRET_LENGTH, F as SESSION_COOKIE_NAME, c as SESSION_PAYLOAD_VERSION, v as assertProductionSessionSecret, P as buildSetCookie, M as checkBearer, N as readCookie, z as resetSessionSecretWarningLatch, g as resolveOptionalSessionSecrets, p as resolveSessionSecret, m as resolveSessionSecrets, I as sessionExpiryFromHeaders, D as signSession, j as timingSafeStringEqual, k as verifySession, A as verifySessionKeyed };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/protocol",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "The Voltro wire + plugin contract — defineQuery/Mutation/Action/Stream, definePlugin, sessions / JWT / API-keys, and the RPC protocol.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -54,7 +54,8 @@
|
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@effect/sql": "^0.52.0",
|
|
57
|
-
"@voltro/database": "0.
|
|
57
|
+
"@voltro/database": "0.34.0",
|
|
58
|
+
"@voltro/logger": "0.34.0",
|
|
58
59
|
"jose": "^6.2.4"
|
|
59
60
|
},
|
|
60
61
|
"peerDependencies": {
|