@voltro/protocol 0.32.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/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
@@ -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: Guards | undefined;
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: Guards | undefined;
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 { 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
package/dist/session.js CHANGED
@@ -1,22 +1,24 @@
1
- import { i as e } from "./auth-CXMrvPyX.js";
2
- import { Schema as t } from "effect";
3
- import { createHmac as n, timingSafeEqual as r } from "node:crypto";
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 i = t.Struct({
6
- subject: e,
7
- exp: t.Number,
8
- iat: t.Number,
9
- kid: t.optional(t.String)
10
- }), a = "k0", o = "k-previous", s = () => {
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
- }, c = () => process.env.VOLTRO_SESSION_SECRET ?? s(), l = () => d() ?? { current: {
13
- kid: u(),
14
- secret: c()
15
- } }, u = () => process.env.VOLTRO_SESSION_KID ?? "k0", d = () => {
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: u(),
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
- }, f = 32, p = (e = process.env) => {
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
- }, m = t.encodeSync(i), h = t.decodeUnknownSync(i), g = (e) => (typeof e == "string" ? Buffer.from(e, "utf8") : e).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""), _ = (e) => {
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
- }, v = 3600 * 24 * 7, y = .7, b = (e, t) => g(n("sha256", t).update(e).digest()), x = (e) => typeof e == "string" ? {
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, S = (e, t, n = {}) => {
40
- let r = x(t);
41
- if (!r.secret) throw Error("signSession: secret must be a non-empty string");
42
- let i = n.ttlSeconds ?? v, a = Math.floor((n.now ?? Date.now)() / 1e3), o = {
43
- subject: e,
44
- exp: a + i,
45
- iat: a,
46
- ...typeof t == "string" ? {} : { kid: r.kid }
47
- }, s = g(JSON.stringify(m(o)));
48
- return `${s}.${b(s, r.secret)}`;
49
- }, C = (e, t, i, a) => {
50
- let o = n("sha256", i).update(e).digest(), s = _(t);
51
- if (s.length !== o.length || !r(s, o)) return null;
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 = _(e).toString("utf8"), n = h(JSON.parse(t));
54
- return n.exp < Math.floor(a() / 1e3) ? null : n;
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
- }, w = (e, t) => {
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 = C(e.slice(0, n), e.slice(n + 1), t, Date.now);
67
+ let r = O(e.slice(0, n), e.slice(n + 1), t, Date.now);
63
68
  return r ? r.subject : null;
64
- }, T = (e, t, n = {}) => {
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 = C(i, a, e.secret, s);
76
+ let t = O(i, a, e.secret, s);
72
77
  if (!t) continue;
73
- let r = n.renewFraction ?? y, o = Math.floor(s() / 1e3), c = t.exp - t.iat, l = t.iat + c * r;
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
- }, E = (e, t) => {
84
- let n = Buffer.from(e, "utf8"), i = Buffer.from(t, "utf8");
85
- return n.length === i.length && r(n, i);
86
- }, D = (e, t, n) => {
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 : E(r.slice(7).trim(), t);
90
- }, O = (e, t) => {
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
- }, k = (e, t, n = {}) => {
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
- }, A = process.env.VOLTRO_SESSION_COOKIE ?? "voltro:session", j = (e) => {
110
- let t = O(e.cookie ?? e.Cookie, A);
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 = d();
113
- if (n) return T(t, n)?.exp;
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 { o as DEFAULT_PREVIOUS_SESSION_KID, a as DEFAULT_SESSION_KID, f as MIN_SESSION_SECRET_LENGTH, A as SESSION_COOKIE_NAME, p as assertProductionSessionSecret, k as buildSetCookie, D as checkBearer, O as readCookie, d as resolveOptionalSessionSecrets, c as resolveSessionSecret, l as resolveSessionSecrets, j as sessionExpiryFromHeaders, S as signSession, E as timingSafeStringEqual, w as verifySession, T as verifySessionKeyed };
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.32.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",
@@ -42,7 +42,8 @@
42
42
  "types": "./dist/apikey.d.ts",
43
43
  "import": "./dist/apikey.js",
44
44
  "default": "./dist/apikey.js"
45
- }
45
+ },
46
+ "./package.json": "./package.json"
46
47
  },
47
48
  "main": "./dist/index.js",
48
49
  "module": "./dist/index.js",
@@ -53,7 +54,8 @@
53
54
  },
54
55
  "dependencies": {
55
56
  "@effect/sql": "^0.52.0",
56
- "@voltro/database": "0.32.0",
57
+ "@voltro/database": "0.34.0",
58
+ "@voltro/logger": "0.34.0",
57
59
  "jose": "^6.2.4"
58
60
  },
59
61
  "peerDependencies": {