@voltro/protocol 0.11.4 → 0.13.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
@@ -425,6 +425,18 @@ declare interface QueryProcedureDescriptor<Name extends string, Input extends Sc
425
425
  readonly exposeAsTool: ExposeAsTool | undefined;
426
426
  }
427
427
 
428
+ /**
429
+ * Passes when the subject holds ANY of `scopes` (or `admin:full`).
430
+ *
431
+ * For a route whose permitted callers differ by REQUEST SHAPE rather than by
432
+ * route — issuing an api key for yourself, for the org, or on behalf of someone
433
+ * else are three different rights on one endpoint. The guard establishes "may
434
+ * mint something"; the handler decides which, because only the body says which
435
+ * was asked for. Splitting them into three routes instead would move a decision
436
+ * that depends on the body into the URL, where a client could pick.
437
+ */
438
+ export declare const requireAnyScope: (scopes: ReadonlyArray<string>) => RestGuard;
439
+
428
440
  /**
429
441
  * Guard that requires the resolved subject to carry `scope`. Anonymous /
430
442
  * scope-less subjects are rejected with `403`. The blanket admin scope
package/dist/rest.js CHANGED
@@ -1,20 +1,20 @@
1
- import { a as e, i as t, o as n, r, t as i } from "./serverErrorBus-B3hDwGoL.js";
2
- import { s as a } from "./auth-DCE6m7Bo.js";
1
+ import { a as e, i as t, o as n, r, t as i } from "./serverErrorBus-DhIVDkCi.js";
2
+ import { s as a } from "./auth-DVrHg739.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) => {
6
6
  let r = e.publicApi;
7
7
  if (r === void 0) throw Error(`publicApiRoute: descriptor '${e.name}' has no publicApi annotation`);
8
- let i = c(e.kind, r), a = l(e.name, r), o = i === "GET" ? "query" : "body", u = s.Struct({ [o]: e.input }), d = (r.scopes ?? []).map((e) => f(e)), g = r.stream === "sse" && e.kind === "query" && n !== void 0, _ = g ? (e, t) => m((r) => n(e[o], t, (e) => {
9
- r(h(String(e._tag ?? "message"), e));
8
+ let i = c(e.kind, r), a = l(e.name, r), o = i === "GET" ? "query" : "body", u = s.Struct({ [o]: e.input }), d = (r.scopes ?? []).map((e) => f(e)), p = r.stream === "sse" && e.kind === "query" && n !== void 0, _ = p ? (e, t) => h((r) => n(e[o], t, (e) => {
9
+ r(g(String(e._tag ?? "message"), e));
10
10
  })) : (e, n) => t(e[o], n);
11
- return p({
11
+ return m({
12
12
  method: i,
13
13
  path: a,
14
14
  input: u,
15
15
  output: e.output,
16
16
  handler: _,
17
- ...g ? { streaming: !0 } : {},
17
+ ...p ? { streaming: !0 } : {},
18
18
  ...d.length > 0 ? { guards: d } : {},
19
19
  ...r.summary === void 0 ? {} : { summary: r.summary },
20
20
  ...r.description === void 0 ? {} : { description: r.description },
@@ -27,26 +27,32 @@ var c = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), l = (e, t) => t.
27
27
  status: 403,
28
28
  message: `Missing required scope: ${e}`
29
29
  };
30
- }, p = (e) => e, m = (e, t = {}) => ({
30
+ }, p = (e) => (t) => {
31
+ let n = t.subject.scopes;
32
+ if (!(n && (n.includes("admin:full") || e.some((e) => n.includes(e))))) return {
33
+ status: 403,
34
+ message: `Missing required scope: one of ${e.join(", ")}`
35
+ };
36
+ }, m = (e) => e, h = (e, t = {}) => ({
31
37
  __voltroRestStream: !0,
32
38
  stream: {
33
39
  subscribe: e,
34
40
  ...t.keepAliveMs === void 0 ? {} : { keepAliveMs: t.keepAliveMs }
35
41
  }
36
- }), h = (e, t) => `event: ${e}\n${(typeof t == "string" ? t : JSON.stringify(t)).split("\n").map((e) => `data: ${e}`).join("\n")}\n\n`, g = (e) => typeof e == "object" && !!e && e.__voltroRestStream === !0, _ = (e, t, n) => ({
42
+ }), g = (e, t) => `event: ${e}\n${(typeof t == "string" ? t : JSON.stringify(t)).split("\n").map((e) => `data: ${e}`).join("\n")}\n\n`, _ = (e) => typeof e == "object" && !!e && e.__voltroRestStream === !0, v = (e, t, n) => ({
37
43
  status: e,
38
44
  contentType: "application/json; charset=utf-8",
39
45
  body: JSON.stringify(t),
40
46
  ...n ? { headers: n } : {}
41
- }), v = (e) => {
47
+ }), y = (e) => {
42
48
  if (e.length === 0) return;
43
49
  let t = new TextDecoder().decode(e);
44
50
  if (t.trim() !== "") return JSON.parse(t);
45
- }, y = (e) => {
51
+ }, b = (e) => {
46
52
  let t = {};
47
53
  for (let [n, r] of new URLSearchParams(e)) t[n] = r;
48
54
  return t;
49
- }, b = (e, t) => {
55
+ }, x = (e, t) => {
50
56
  let n = e.split("/").filter((e) => e.length > 0), r = t.split("/").filter((e) => e.length > 0), i = {};
51
57
  for (let e = 0; e < n.length; e++) {
52
58
  let t = n[e];
@@ -60,22 +66,22 @@ var c = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), l = (e, t) => t.
60
66
  }
61
67
  }
62
68
  return i;
63
- }, x = (e, t) => ({
64
- query: y(e.query),
69
+ }, S = (e, t) => ({
70
+ query: b(e.query),
65
71
  params: t,
66
- body: v(e.rawBody)
67
- }), S = /* @__PURE__ */ new Set([
72
+ body: y(e.rawBody)
73
+ }), C = /* @__PURE__ */ new Set([
68
74
  "POST",
69
75
  "PUT",
70
76
  "PATCH",
71
77
  "DELETE"
72
- ]), C = (c, l) => {
78
+ ]), w = (c, l) => {
73
79
  let u = c.input ? s.decodeUnknown(c.input) : void 0, d = s.encode(c.output), f = {};
74
80
  return c.deprecated !== void 0 && (f.Deprecation = "true"), c.sunset !== void 0 && (f.Sunset = c.sunset), {
75
81
  method: "*",
76
82
  path: c.path,
77
83
  handle: async (s) => {
78
- if (s.method.toUpperCase() !== c.method) return _(405, {
84
+ if (s.method.toUpperCase() !== c.method) return v(405, {
79
85
  error: "Method Not Allowed",
80
86
  allow: c.method
81
87
  }, {
@@ -84,21 +90,21 @@ var c = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), l = (e, t) => t.
84
90
  });
85
91
  if (c.sunset !== void 0) {
86
92
  let e = Date.parse(c.sunset);
87
- if (!Number.isNaN(e) && Date.now() >= e) return _(410, {
93
+ if (!Number.isNaN(e) && Date.now() >= e) return v(410, {
88
94
  error: "Gone",
89
95
  ...c.deprecated === void 0 ? {} : { replacement: c.deprecated }
90
96
  }, f);
91
97
  }
92
98
  let p;
93
99
  if (u) {
94
- let e = x(s, b(c.path, s.path)), t = await o.runPromise(u(e).pipe(o.map((e) => ({
100
+ let e = S(s, x(c.path, s.path)), t = await o.runPromise(u(e).pipe(o.map((e) => ({
95
101
  ok: !0,
96
102
  value: e
97
103
  })), o.catchAll((e) => o.succeed({
98
104
  ok: !1,
99
105
  cause: e
100
106
  }))));
101
- if (!t.ok) return _(400, {
107
+ if (!t.ok) return v(400, {
102
108
  error: "Invalid request",
103
109
  detail: String(t.cause)
104
110
  }, f);
@@ -111,47 +117,47 @@ var c = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), l = (e, t) => t.
111
117
  };
112
118
  if (c.guards) for (let e of c.guards) {
113
119
  let t = await e(h);
114
- if (t) return _(t.status, { error: t.message }, f);
120
+ if (t) return v(t.status, { error: t.message }, f);
115
121
  }
116
- let v = l.idempotency, y = v && S.has(c.method) ? s.headers[v.header.toLowerCase()] : void 0, C = v && y ? n(m.tenantId, c.method, c.path) : "";
117
- if (v && y) {
118
- let e = await r(v.store, C, y, v.ttlMs, Date.now());
119
- if (e.kind === "replay") return _(e.response.status, e.response.body, {
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) : "";
123
+ if (g && y) {
124
+ let e = await r(g.store, b, y, g.ttlMs, Date.now());
125
+ if (e.kind === "replay") return v(e.response.status, e.response.body, {
120
126
  ...f,
121
127
  "Idempotency-Replayed": "true"
122
128
  });
123
- if (e.kind === "conflict") return _(409, { error: "A request with this Idempotency-Key is already being processed" }, f);
129
+ if (e.kind === "conflict") return v(409, { error: "A request with this Idempotency-Key is already being processed" }, f);
124
130
  }
125
131
  try {
126
132
  let t = await c.handler(p, h);
127
- if (g(t)) return {
133
+ if (_(t)) return {
128
134
  status: 200,
129
135
  headers: f,
130
136
  stream: t.stream
131
137
  };
132
138
  let n = await o.runPromise(d(t));
133
- return v && y && await e(v.store, C, y, {
139
+ return g && y && await e(g.store, b, y, {
134
140
  status: 200,
135
141
  body: n
136
- }, Date.now()), _(200, n, f);
142
+ }, Date.now()), v(200, n, f);
137
143
  } catch (e) {
138
- if (v && y && await t(v.store, C, y), typeof e == "object" && e && typeof e.status == "number") {
144
+ if (g && y && await t(g.store, b, y), typeof e == "object" && e && typeof e.status == "number") {
139
145
  let t = e;
140
- return _(t.status, { error: t.message ?? "Error" }, f);
146
+ return v(t.status, { error: t.message ?? "Error" }, f);
141
147
  }
142
148
  return i({
143
149
  error: e,
144
150
  source: "rest",
145
151
  name: `${c.method} ${c.path}`,
146
152
  fields: { status: 500 }
147
- }), _(500, { error: "Internal Server Error" }, f);
153
+ }), v(500, { error: "Internal Server Error" }, f);
148
154
  }
149
155
  }
150
156
  };
151
- }, w = (e, t = {}) => e.map((e) => C(e, t)), T = async (e, t) => {
157
+ }, T = (e, t = {}) => e.map((e) => w(e, t)), E = async (e, t) => {
152
158
  let n = await e[0].handle(t);
153
159
  for (let r = 1; r < e.length && n.status === 405; r++) n = await e[r].handle(t);
154
160
  return n;
155
161
  };
156
162
  //#endregion
157
- export { d as collectPublicApiRoutes, p as defineRestRoute, c as derivePublicMethod, l as derivePublicPath, T as dispatchSharedPath, g as isRestStreamResponse, b as matchPathParams, u as publicApiRoute, f as requireScope, w as restRoutesToHttpRoutes, m as sse, h as sseFrame };
163
+ export { d as collectPublicApiRoutes, m as defineRestRoute, c as derivePublicMethod, l as derivePublicPath, E as dispatchSharedPath, _ as isRestStreamResponse, x as matchPathParams, u as publicApiRoute, p as requireAnyScope, f as requireScope, T as restRoutesToHttpRoutes, h as sse, g as sseFrame };
Binary file
package/dist/session.d.ts CHANGED
@@ -19,14 +19,32 @@ export declare const buildSetCookie: (name: string, value: string, options?: Set
19
19
  /**
20
20
  * Check an `Authorization: Bearer <token>` header against an expected token.
21
21
  *
22
- * - `expected` unset/empty → `true` (no token configured = open; the caller
23
- * decided not to gate this surface).
22
+ * - `expected` unset/empty → `false`, UNLESS the caller passes
23
+ * `{ openWhenUnset: true }`. See below.
24
24
  * - header absent, or not using the `Bearer ` scheme → `false`.
25
25
  * - presented token compared to `expected` in CONSTANT TIME.
26
26
  *
27
27
  * `headers` keys may be any casing (`authorization` / `Authorization`).
28
+ *
29
+ * **Why the default is closed.** It used to be open: an unset or empty
30
+ * `expected` returned `true`, on the reading that "no token configured" meant
31
+ * the caller chose not to gate the surface. That reading is only available to
32
+ * the caller, and one of them declared the opposite — `scimPlugin`'s `token` is
33
+ * a REQUIRED `string`, and `token: process.env.SCIM_TOKEN ?? ''` (the shape
34
+ * anyone writes) silently turned the gate off. The result was SCIM 2.0 Users
35
+ * and Groups readable with no credentials: a full directory dump plus the
36
+ * provisioning surface that can deactivate accounts. The failure was silent and
37
+ * likeliest exactly where it hurts — an env var set in production and unset in
38
+ * a preview environment or a fresh cluster.
39
+ *
40
+ * A two-argument helper cannot know its caller's intent, so it must not assume
41
+ * the permissive one. `openWhenUnset` makes the choice appear at the call site,
42
+ * where the caller who genuinely wants an ungated endpoint says so and the
43
+ * caller who forgot gets a 401 instead of an open door.
28
44
  */
29
- export declare const checkBearer: (headers: Readonly<Record<string, string | undefined>>, expected: string | undefined) => boolean;
45
+ export declare const checkBearer: (headers: Readonly<Record<string, string | undefined>>, expected: string | undefined, options?: {
46
+ readonly openWhenUnset?: boolean;
47
+ }) => boolean;
30
48
 
31
49
  /** Key id assigned to `VOLTRO_SESSION_SECRET_PREVIOUS` when no explicit
32
50
  * `VOLTRO_SESSION_KID_PREVIOUS` is set. The kid is a non-secret label —
package/dist/session.js CHANGED
@@ -1,4 +1,4 @@
1
- import { i as e } from "./auth-DCE6m7Bo.js";
1
+ import { i as e } from "./auth-DVrHg739.js";
2
2
  import { Schema as t } from "effect";
3
3
  import { createHmac as n, timingSafeEqual as r } from "node:crypto";
4
4
  //#region src/session.ts
@@ -83,10 +83,10 @@ var i = t.Struct({
83
83
  }, E = (e, t) => {
84
84
  let n = Buffer.from(e, "utf8"), i = Buffer.from(t, "utf8");
85
85
  return n.length === i.length && r(n, i);
86
- }, D = (e, t) => {
87
- if (!t || t.length === 0) return !0;
88
- let n = e.authorization ?? e.Authorization;
89
- return !n || !n.startsWith("Bearer ") ? !1 : E(n.slice(7).trim(), t);
86
+ }, D = (e, t, n) => {
87
+ if (!t || t.length === 0) return n?.openWhenUnset === !0;
88
+ let r = e.authorization ?? e.Authorization;
89
+ return !r || !r.startsWith("Bearer ") ? !1 : E(r.slice(7).trim(), t);
90
90
  }, O = (e, t) => {
91
91
  if (e) for (let n of e.split(";")) {
92
92
  let e = n.indexOf("=");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/protocol",
3
- "version": "0.11.4",
3
+ "version": "0.13.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",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@effect/sql": "^0.51.1",
56
- "@voltro/database": "0.11.4",
56
+ "@voltro/database": "0.13.0",
57
57
  "jose": "^6.2.3"
58
58
  },
59
59
  "peerDependencies": {
Binary file