@voltro/protocol 0.11.3 → 0.12.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/apikey.js CHANGED
@@ -1,28 +1,34 @@
1
1
  import { createHash as e } from "node:crypto";
2
2
  //#region src/apiKeyStrategy.ts
3
3
  var t = (t) => e("sha256").update(t, "utf8").digest("hex"), n = (e) => {
4
- let n = e.id ?? "voltro-apikey";
4
+ let t = {};
5
+ for (let [n, r] of Object.entries(e)) r !== void 0 && (t[n] = r);
6
+ return t;
7
+ }, r = (e) => {
8
+ let r = e.id ?? "voltro-apikey";
5
9
  return {
6
- id: n,
7
- resolve: async (r) => {
8
- let i = r.headers.authorization ?? r.headers.Authorization;
9
- if (!i) return { kind: "skip" };
10
- let a = /^Bearer\s+(.+)$/i.exec(i.trim());
10
+ id: r,
11
+ resolve: async (i) => {
12
+ let a = i.headers.authorization ?? i.headers.Authorization;
11
13
  if (!a) return { kind: "skip" };
12
- let o = a[1].trim(), s = e.prefix ?? "";
13
- if (s && !o.startsWith(s)) return { kind: "skip" };
14
- let c = await e.resolveKey(t(o));
15
- return c ? {
14
+ let o = /^Bearer\s+(.+)$/i.exec(a.trim());
15
+ if (!o) return { kind: "skip" };
16
+ let s = o[1].trim(), c = e.prefix ?? "";
17
+ if (c && !s.startsWith(c)) return { kind: "skip" };
18
+ let l = await e.resolveKey(t(s));
19
+ return l ? {
16
20
  kind: "matched",
17
21
  subject: {
18
22
  type: "apiKey",
19
- id: c.id,
20
- tenantId: c.tenantId,
21
- scopes: c.scopes,
22
- metadata: c.createdBy == null ? { provider: n } : {
23
- provider: n,
24
- userId: c.createdBy
25
- }
23
+ id: l.id,
24
+ tenantId: l.tenantId,
25
+ scopes: l.scopes,
26
+ metadata: n({
27
+ ...l.metadata ?? {},
28
+ provider: r,
29
+ userId: l.onBehalfOf ?? void 0,
30
+ createdBy: l.createdBy ?? void 0
31
+ })
26
32
  }
27
33
  } : {
28
34
  kind: "failed",
@@ -32,4 +38,4 @@ var t = (t) => e("sha256").update(t, "utf8").digest("hex"), n = (e) => {
32
38
  };
33
39
  };
34
40
  //#endregion
35
- export { n as apiKeyStrategy };
41
+ export { r as apiKeyStrategy };
@@ -41,7 +41,16 @@ var r = t.Record({
41
41
  id: e,
42
42
  tenantId: null,
43
43
  scopes: t
44
- }), p = (e) => e.type === "system", m = class extends e.Tag("@voltro/Subject")() {}, h = class extends e.Tag("@voltro/ConnectionInfo")() {}, g = class extends n.Tag()("@voltro/AuthMiddleware", { provides: m }) {}, _ = class extends n.Tag()("@voltro/ConnectionInfoMiddleware", { provides: h }) {}, v = (e) => typeof e.mountRoutes == "function", y = (e, t) => async (n) => {
44
+ }), p = (e, t) => {
45
+ let n = e;
46
+ return {
47
+ type: "serviceAccount",
48
+ id: e.id ?? "system",
49
+ tenantId: t,
50
+ scopes: n.scopes ?? ["admin:full"],
51
+ ...n.metadata === void 0 ? {} : { metadata: n.metadata }
52
+ };
53
+ }, m = (e) => e.type === "system", h = class extends e.Tag("@voltro/Subject")() {}, g = class extends e.Tag("@voltro/ConnectionInfo")() {}, _ = class extends n.Tag()("@voltro/AuthMiddleware", { provides: h }) {}, v = class extends n.Tag()("@voltro/ConnectionInfoMiddleware", { provides: g }) {}, y = (e) => typeof e.mountRoutes == "function", b = (e, t) => async (n) => {
45
54
  for (let r of e) {
46
55
  let e = await r.resolve(n);
47
56
  if (e.kind === "matched") return e.subject;
@@ -55,10 +64,10 @@ var r = t.Record({
55
64
  }
56
65
  if (t?.fallback) return t.fallback(n);
57
66
  let r = n.headers["x-tenant"] ?? null;
58
- if (r === null && t?.anonymousTenantRequired === !0) throw new b({ reason: "tenant required (x-tenant header missing)" });
67
+ if (r === null && t?.anonymousTenantRequired === !0) throw new x({ reason: "tenant required (x-tenant header missing)" });
59
68
  return d(r);
60
- }, b = class extends t.TaggedError()("Unauthenticated", { reason: t.optional(t.String) }) {}, x = (e, t) => {
61
- if (e.type === "anonymous") throw new b(t === void 0 ? {} : { reason: t });
69
+ }, x = class extends t.TaggedError()("Unauthenticated", { reason: t.optional(t.String) }) {}, S = (e, t) => {
70
+ if (e.type === "anonymous") throw new x(t === void 0 ? {} : { reason: t });
62
71
  };
63
72
  //#endregion
64
- export { m as a, x as c, p as d, f, u as i, y as l, h as n, b as o, _ as r, d as s, g as t, v as u };
73
+ export { h as a, S as c, m as d, f, u as i, b as l, g as n, x as o, p, v as r, d as s, _ as t, y as u };
package/dist/index.d.ts CHANGED
@@ -39,6 +39,37 @@ export declare type AnyCheckSpec = GuardCheckSpec | PolicyCheckSpec;
39
39
  /** A guard is either a scope check or a relationship check. */
40
40
  export declare type AnyGuardSpec<Input = unknown> = GuardSpec<Input> | PolicyGuardSpec<Input>;
41
41
 
42
+ export declare const APIKEY_ISSUE_ORG_SCOPE = "apikeys:issue:org";
43
+
44
+ export declare const APIKEY_ISSUE_OTHER_SCOPE = "apikeys:issue:other";
45
+
46
+ /**
47
+ * API-key issuance rights.
48
+ *
49
+ * One `admin:full` gate for all key minting was too coarse to express what
50
+ * products actually need: it means only a full admin can ever mint a key, so a
51
+ * normal user cannot create their own even narrowly-scoped credential, and an
52
+ * admin minting one FOR someone is indistinguishable from minting one for
53
+ * themselves.
54
+ *
55
+ * Three separable capabilities instead:
56
+ *
57
+ * `apikeys:issue:self` - mint a key that acts as ME. The common self-service
58
+ * case; the key can never exceed the holder's own
59
+ * scopes.
60
+ * `apikeys:issue:org` - mint an ORG key: acts as no person, belongs to the
61
+ * organization. A CI credential. Separate because
62
+ * "may create a personal token" and "may create a
63
+ * credential that outlives my account" are genuinely
64
+ * different levels of trust.
65
+ * `apikeys:issue:other` - mint a key ON BEHALF OF another user. Admin
66
+ * territory: it is the ability to act as someone
67
+ * else, so it is never implied by the other two.
68
+ *
69
+ * `admin:full` satisfies all three, as it does every scope.
70
+ */
71
+ export declare const APIKEY_ISSUE_SELF_SCOPE = "apikeys:issue:self";
72
+
42
73
  /**
43
74
  * Apply a `RowPatch` to `prev`, producing `next`. Exact inverse of
44
75
  * `diffRows`: `applyRowPatch(prev, diffRows(prev, next))` deep-equals
@@ -1257,6 +1288,27 @@ export declare interface PluginHttpRouteResult {
1257
1288
  readonly body?: string | Uint8Array;
1258
1289
  readonly contentType?: string;
1259
1290
  readonly headers?: Record<string, string>;
1291
+ /** Stream the response (SSE) instead of sending `body`. See
1292
+ * {@link PluginHttpRouteStream}. */
1293
+ readonly stream?: PluginHttpRouteStream;
1294
+ }
1295
+
1296
+ /**
1297
+ * A long-lived Server-Sent-Events body. When a route result carries this, the
1298
+ * serve layer streams the response until the client disconnects instead of
1299
+ * sending a buffered body — `body` is ignored.
1300
+ *
1301
+ * `subscribe` receives an `emit` that takes ONE already-SSE-framed chunk (e.g.
1302
+ * `` `event: snapshot\ndata: ${json}\n\n` ``) and MUST return an unsubscribe
1303
+ * function. The serve layer runs that unsubscribe when the client goes away, so
1304
+ * whatever the route opened (a dispatcher subscription, an interval) is released
1305
+ * — a stream route that leaks its subscription leaks it per connection.
1306
+ */
1307
+ export declare interface PluginHttpRouteStream {
1308
+ readonly subscribe: (emit: (chunk: string) => void) => () => void;
1309
+ /** Keep-alive comment interval in ms (default 15000; `0` disables). Without
1310
+ * it an idle SSE connection is dropped by proxies after ~30–60s. */
1311
+ readonly keepAliveMs?: number;
1260
1312
  }
1261
1313
 
1262
1314
  export declare interface PluginInspectEndpoint {
@@ -2201,6 +2253,25 @@ export declare type Target<Input = unknown, Row = unknown> = TargetSpec<Input, R
2201
2253
 
2202
2254
  export declare type TargetSpec<Input = unknown, Row = unknown> = InsertTarget<Input, Row> | UpdateTarget<Input, Row> | DeleteTarget<Input>;
2203
2255
 
2256
+ /**
2257
+ * The subject a `storeForTenant(id)` view runs as — the caller's identity,
2258
+ * re-pointed at ONE explicit tenant.
2259
+ *
2260
+ * Lives here, next to the other subject constructors, because BOTH the serve
2261
+ * context builder (`@voltro/cli`) and the test harness (`@voltro/testing`)
2262
+ * must produce the identical subject. When it lived in the CLI, the harness
2263
+ * could not reach it, and `ctx.storeForTenant` was simply absent under test —
2264
+ * a handler that used it had no way to be tested at all.
2265
+ *
2266
+ * The narrowed return type is the point, not decoration: it states at the type
2267
+ * level that this can never hand back a `system` subject, which is the variant
2268
+ * whose null tenant means "all tenants". Producing one here would silently
2269
+ * widen a deliberately narrow view back to every tenant.
2270
+ */
2271
+ export declare const tenantScopedSubject: (subject: Subject, tenantId: string) => Extract<Subject, {
2272
+ readonly type: "serviceAccount";
2273
+ }>;
2274
+
2204
2275
  export declare const toRpc: <Name extends string, Input extends Schema.Schema.Any, Output extends Schema.Schema.Any, Err extends Schema.Schema.All>(descriptor: QueryProcedureDescriptor<Name, Input, Output, Err> | MutationProcedureDescriptor<Name, Input, Output, Err> | ActionProcedureDescriptor<Name, Input, Output, Err> | StreamProcedureDescriptor<Name, Input, Output, Err>) => Rpc.Rpc<Name, Input extends Schema.Struct.Fields ? Schema.Struct<Input> : Input, Stream<Schema.Union<[Schema.Struct<{
2205
2276
  _tag: Schema.Literal<["snapshot"]>;
2206
2277
  revision: typeof Schema.Number;
@@ -2428,6 +2499,24 @@ export declare interface VoltroPlugin {
2428
2499
  * dashboard Env panel. Mirror exactly what the plugin reads.
2429
2500
  */
2430
2501
  readonly declaredEnv?: ReadonlyArray<PluginEnvVar>;
2502
+ /**
2503
+ * The authorization scopes this plugin DEFINES for the app — its scope
2504
+ * vocabulary. Distinct from `permissions` above, which is what the plugin
2505
+ * itself asks to be granted.
2506
+ *
2507
+ * `@voltro/plugin-rbac` fills this with every scope its `roles` map grants,
2508
+ * because that map already IS the app's declared vocabulary. The capability
2509
+ * manifest unions these, and `voltro check` compares each handler's required
2510
+ * scopes against the union: a guard demanding a scope no role can grant (a
2511
+ * typo, a rename) makes that procedure permanently uncallable, silently.
2512
+ *
2513
+ * Only declare this when the list is EXHAUSTIVE. A plugin that also grants
2514
+ * scopes from a dynamic source (a custom resolver, per-row ACLs) must leave
2515
+ * it undefined — the check treats "no declared scopes" as "cannot conclude"
2516
+ * and stays dormant, which is the honest outcome. A partial list would flag
2517
+ * correct code, and a check that cries wolf gets ignored.
2518
+ */
2519
+ readonly declaredScopes?: ReadonlyArray<string>;
2431
2520
  /**
2432
2521
  * Optional `effect/Schema` describing the plugin's user-supplied
2433
2522
  * config. When present the framework decodes the operator's config