@voltro/protocol 0.25.0 → 0.27.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.d.ts CHANGED
@@ -150,15 +150,28 @@ declare interface AuthStrategyInput {
150
150
  readonly store?: DataStore;
151
151
  }
152
152
 
153
- /** A strategy's verdict on a request.
154
- * - `matched`: this strategy claims the request; here's the Subject.
155
- * - `skip`: not my request (e.g. cookie absent); try next strategy.
156
- * - `failed`: this IS my request BUT validation failed (signature
157
- * mismatch, expired JWT). Composer bails to anonymous + logs;
158
- * silently falling through would mask attacks. */
159
153
  declare type StrategyResolution = {
160
154
  readonly kind: 'matched';
161
155
  readonly subject: Subject;
156
+ /**
157
+ * When this credential expires, in unix SECONDS — if the strategy knows.
158
+ *
159
+ * The strategy is the ONLY place in the system that has verified the token
160
+ * and holds its `exp`, and until now it could not say so. The credential
161
+ * bound therefore read one source: the `voltro:session` cookie. For an app
162
+ * authenticating with Bearer JWTs — six of our own catalog strategies do,
163
+ * and every one of them verifies an `exp` — it was silently `undefined`,
164
+ * so "a subscription can no longer outlive the credential that authorized
165
+ * it" was a no-op that read as a guarantee.
166
+ *
167
+ * A reporter found it by expecting black screens an hour after a deploy
168
+ * and getting none. Their conclusion is the one to keep: the guarantee was
169
+ * not false, it was scoped to an auth shape the sentence did not name.
170
+ *
171
+ * Optional, and absent still means no bound — the failure direction is the
172
+ * behaviour that already existed.
173
+ */
174
+ readonly credentialExpiresAt?: number;
162
175
  } | {
163
176
  readonly kind: 'skip';
164
177
  } | {
@@ -64,7 +64,13 @@ var r = t.Record({
64
64
  };
65
65
  for (let n of e) {
66
66
  let e = await n.resolve(i);
67
- if (e.kind === "matched") return t?.resolveScopes === void 0 ? e.subject : b(e.subject, await t.resolveScopes(e.subject, i));
67
+ if (e.kind === "matched") {
68
+ let n = t?.resolveScopes === void 0 ? e.subject : b(e.subject, await t.resolveScopes(e.subject, i));
69
+ return e.credentialExpiresAt === void 0 ? { subject: n } : {
70
+ subject: n,
71
+ credentialExpiresAt: e.credentialExpiresAt
72
+ };
73
+ }
68
74
  if (e.kind === "failed") {
69
75
  t?.onStrategyFailed?.({
70
76
  strategyId: n.id,
@@ -73,10 +79,10 @@ var r = t.Record({
73
79
  break;
74
80
  }
75
81
  }
76
- if (t?.fallback) return t.fallback(i);
82
+ if (t?.fallback) return { subject: await t.fallback(i) };
77
83
  let a = i.headers["x-tenant"] ?? null;
78
84
  if (a === null && t?.anonymousTenantRequired === !0) throw new S({ reason: "tenant required (x-tenant header missing)" });
79
- return d(a);
85
+ return { subject: d(a) };
80
86
  }, S = class extends t.TaggedError()("Unauthenticated", { reason: t.optional(t.String) }) {}, C = (e, t) => {
81
87
  if (e.type === "anonymous") throw new S(t === void 0 ? {} : { reason: t });
82
88
  };
package/dist/index.d.ts CHANGED
@@ -38,6 +38,28 @@ export declare interface ActionProcedureDescriptor<Name extends string, Input ex
38
38
  readonly exposeAsTool: ExposeAsTool | undefined;
39
39
  /** True when the procedure is kept OFF the wire — no client-group entry and no
40
40
  * route in dev or serve. See `internal` on the definer's options. */
41
+ /**
42
+ * Replace a PLUGIN route that answers to this same tag.
43
+ *
44
+ * Without it, a user route and a plugin route sharing a tag is a hard error,
45
+ * and correctly so — two handlers behind one name is not a thing a caller can
46
+ * reason about. But refusing is the wrong answer when the app deliberately
47
+ * wants its own version: the two escapes available otherwise are to rename
48
+ * your procedure (so the split runs along "who built it" rather than along a
49
+ * domain boundary) or to `alias` the whole plugin away (same, one level up).
50
+ * For a frontend developer that is the worst possible partition.
51
+ *
52
+ * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose
53
+ * surface is richer than theirs, add `archive`/`unarchive` beside it — which
54
+ * already composes, since the collision check compares FULL tags and not
55
+ * prefixes — and replace `markRead`, because theirs maintains archive state.
56
+ *
57
+ * Explicit, never inferred. Silently letting the app win would mean a plugin
58
+ * upgrade that adds a route could shadow an app procedure with no diff to
59
+ * read; declaring it makes the intent reviewable and puts the override in the
60
+ * file that performs it.
61
+ */
62
+ readonly overridesPlugin: boolean | undefined;
41
63
  readonly internal: boolean | undefined;
42
64
  }
43
65
 
@@ -414,7 +436,7 @@ export declare const composeAuthStrategies: (strategies: ReadonlyArray<AuthStrat
414
436
  * class this repo has been bitten by most.
415
437
  */
416
438
  readonly getStore?: () => unknown;
417
- }) => ((input: AuthStrategyInput) => Promise<Subject>);
439
+ }) => ((input: AuthStrategyInput) => Promise<SubjectResolution>);
418
440
 
419
441
  /**
420
442
  * Compose a list of Effect-native interceptors into ONE function the
@@ -488,6 +510,28 @@ export declare interface ConnectionInfoValue {
488
510
  * (every `useMutation` call does). Read by `bindMutation` to dedupe a retried
489
511
  * mutation. Absent for callers that don't send it. */
490
512
  readonly idempotencyKey?: string;
513
+ /**
514
+ * Unix-SECONDS expiry of the credential that authorized this call, when it
515
+ * has one. Absent for credentials with no expiry (anonymous, a non-expiring
516
+ * strategy) — and absent means "no bound", so the failure direction is the
517
+ * behaviour that already existed.
518
+ *
519
+ * It exists for LONG-LIVED work. A request is checked once and is over in
520
+ * milliseconds, so expiry never mattered; an event subscription is a
521
+ * standing state that reconnects forever by design, so one opened a minute
522
+ * before the token dies would otherwise keep delivering for days on a
523
+ * credential that is long gone. `bindEvent` ends the stream here, and
524
+ * `useEvent`'s existing reconnect immediately re-opens it — which is a NEW
525
+ * request, so it re-resolves the subject and re-runs the guards for real.
526
+ * That is what makes the bound seamless rather than a disconnection the app
527
+ * has to handle: still entitled, it continues; no longer entitled, it fails
528
+ * loudly instead of quietly continuing.
529
+ *
530
+ * This bounds EXPIRY, not revocation. A role revoked mid-session is not
531
+ * observed until the credential runs out — do not let this field grow a
532
+ * doc comment that claims otherwise.
533
+ */
534
+ readonly credentialExpiresAt?: number;
491
535
  }
492
536
 
493
537
  /** The two credential shapes a connection can hold. `oauth2` = an
@@ -694,6 +738,9 @@ export declare const defineAction: <const Name extends string, Input extends Sch
694
738
  * need to check who is asking.
695
739
  */
696
740
  readonly internal?: boolean;
741
+ /** Replace a PLUGIN route answering to this same tag. Explicit, never
742
+ * inferred — see `overridesPlugin` on the descriptor. */
743
+ readonly overridesPlugin?: boolean;
697
744
  }) => ActionProcedureDescriptor<Name, Input, Output, Error>;
698
745
 
699
746
  /**
@@ -825,6 +872,9 @@ export declare const defineMutation: <const Name extends string, Input extends S
825
872
  * need to check who is asking.
826
873
  */
827
874
  readonly internal?: boolean;
875
+ /** Replace a PLUGIN route answering to this same tag. Explicit, never
876
+ * inferred — see `overridesPlugin` on the descriptor. */
877
+ readonly overridesPlugin?: boolean;
828
878
  }) => MutationProcedureDescriptor<Name, Input, Output, Error>;
829
879
 
830
880
  /**
@@ -919,6 +969,9 @@ export declare const defineQuery: <const Name extends string, Input extends Sche
919
969
  * need to check who is asking.
920
970
  */
921
971
  readonly internal?: boolean;
972
+ /** Replace a PLUGIN route answering to this same tag. Explicit, never
973
+ * inferred — see `overridesPlugin` on the descriptor. */
974
+ readonly overridesPlugin?: boolean;
922
975
  }) => QueryProcedureDescriptor<Name, Input, Output, Error>;
923
976
 
924
977
  /**
@@ -935,6 +988,22 @@ export declare const defineStream: <const Name extends string, Input extends Sch
935
988
  * dev or serve. Same contract as `internal` on the other definers; a stream
936
989
  * without it would be a hole in the same boundary. */
937
990
  readonly internal?: boolean;
991
+ /** Replace a PLUGIN route answering to this same tag. Explicit, never
992
+ * inferred — see `overridesPlugin` on the descriptor. */
993
+ readonly overridesPlugin?: boolean;
994
+ /**
995
+ * WHO MAY LISTEN.
996
+ *
997
+ * A stream is the same long-lived grant a subscription is, and it was the one
998
+ * primitive that could not express authorization at all — queries, mutations
999
+ * and actions carry `guards:`, streams did not, so any protection lived
1000
+ * hand-written inside an executor where nothing could verify it existed.
1001
+ *
1002
+ * Checked at subscribe and re-checked before every element, so a resource
1003
+ * un-shared or a membership ended stops the stream rather than continuing to
1004
+ * push. Same shape and same semantics as a query's.
1005
+ */
1006
+ readonly guards?: Guards;
938
1007
  }) => StreamProcedureDescriptor<Name, Input, Element, Error>;
939
1008
 
940
1009
  export declare interface DeleteTarget<Input = unknown> extends NestedTargetFields<Input> {
@@ -1269,11 +1338,6 @@ export declare interface EventWebhookSpec {
1269
1338
  readonly description?: string;
1270
1339
  /** Payload schema version. Bump when subscribers must adapt. Default 1. */
1271
1340
  readonly version?: number;
1272
- /** Default retry policy for new subscriptions (`{ attempts, backoffMs }`). */
1273
- readonly retry?: {
1274
- readonly attempts?: number;
1275
- readonly backoffMs?: number;
1276
- };
1277
1341
  /** Shared ceiling across ALL deliveries of this event — the runaway-emit
1278
1342
  * guard. Over-limit deliveries are deferred, never dropped. */
1279
1343
  readonly rateLimit?: {
@@ -1597,6 +1661,28 @@ export declare interface MutationProcedureDescriptor<Name extends string, Input
1597
1661
  readonly exposeAsTool: ExposeAsTool | undefined;
1598
1662
  /** True when the procedure is kept OFF the wire — no client-group entry and no
1599
1663
  * route in dev or serve. See `internal` on the definer's options. */
1664
+ /**
1665
+ * Replace a PLUGIN route that answers to this same tag.
1666
+ *
1667
+ * Without it, a user route and a plugin route sharing a tag is a hard error,
1668
+ * and correctly so — two handlers behind one name is not a thing a caller can
1669
+ * reason about. But refusing is the wrong answer when the app deliberately
1670
+ * wants its own version: the two escapes available otherwise are to rename
1671
+ * your procedure (so the split runs along "who built it" rather than along a
1672
+ * domain boundary) or to `alias` the whole plugin away (same, one level up).
1673
+ * For a frontend developer that is the worst possible partition.
1674
+ *
1675
+ * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose
1676
+ * surface is richer than theirs, add `archive`/`unarchive` beside it — which
1677
+ * already composes, since the collision check compares FULL tags and not
1678
+ * prefixes — and replace `markRead`, because theirs maintains archive state.
1679
+ *
1680
+ * Explicit, never inferred. Silently letting the app win would mean a plugin
1681
+ * upgrade that adds a route could shadow an app procedure with no diff to
1682
+ * read; declaring it makes the intent reviewable and puts the override in the
1683
+ * file that performs it.
1684
+ */
1685
+ readonly overridesPlugin: boolean | undefined;
1600
1686
  readonly internal: boolean | undefined;
1601
1687
  }
1602
1688
 
@@ -2533,6 +2619,28 @@ export declare interface QueryProcedureDescriptor<Name extends string, Input ext
2533
2619
  readonly exposeAsTool: ExposeAsTool | undefined;
2534
2620
  /** True when the procedure is kept OFF the wire — no client-group entry and no
2535
2621
  * route in dev or serve. See `internal` on the definer's options. */
2622
+ /**
2623
+ * Replace a PLUGIN route that answers to this same tag.
2624
+ *
2625
+ * Without it, a user route and a plugin route sharing a tag is a hard error,
2626
+ * and correctly so — two handlers behind one name is not a thing a caller can
2627
+ * reason about. But refusing is the wrong answer when the app deliberately
2628
+ * wants its own version: the two escapes available otherwise are to rename
2629
+ * your procedure (so the split runs along "who built it" rather than along a
2630
+ * domain boundary) or to `alias` the whole plugin away (same, one level up).
2631
+ * For a frontend developer that is the worst possible partition.
2632
+ *
2633
+ * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose
2634
+ * surface is richer than theirs, add `archive`/`unarchive` beside it — which
2635
+ * already composes, since the collision check compares FULL tags and not
2636
+ * prefixes — and replace `markRead`, because theirs maintains archive state.
2637
+ *
2638
+ * Explicit, never inferred. Silently letting the app win would mean a plugin
2639
+ * upgrade that adds a route could shadow an app procedure with no diff to
2640
+ * read; declaring it makes the intent reviewable and puts the override in the
2641
+ * file that performs it.
2642
+ */
2643
+ readonly overridesPlugin: boolean | undefined;
2536
2644
  readonly internal: boolean | undefined;
2537
2645
  }
2538
2646
 
@@ -2858,15 +2966,28 @@ export declare const setPolicyGuardResolver: (resolver: PolicyGuardResolver | un
2858
2966
  */
2859
2967
  export declare const setResourceScopeResolver: (resolver: ResourceScopeResolver | undefined) => void;
2860
2968
 
2861
- /** A strategy's verdict on a request.
2862
- * - `matched`: this strategy claims the request; here's the Subject.
2863
- * - `skip`: not my request (e.g. cookie absent); try next strategy.
2864
- * - `failed`: this IS my request BUT validation failed (signature
2865
- * mismatch, expired JWT). Composer bails to anonymous + logs;
2866
- * silently falling through would mask attacks. */
2867
2969
  export declare type StrategyResolution = {
2868
2970
  readonly kind: 'matched';
2869
2971
  readonly subject: Subject;
2972
+ /**
2973
+ * When this credential expires, in unix SECONDS — if the strategy knows.
2974
+ *
2975
+ * The strategy is the ONLY place in the system that has verified the token
2976
+ * and holds its `exp`, and until now it could not say so. The credential
2977
+ * bound therefore read one source: the `voltro:session` cookie. For an app
2978
+ * authenticating with Bearer JWTs — six of our own catalog strategies do,
2979
+ * and every one of them verifies an `exp` — it was silently `undefined`,
2980
+ * so "a subscription can no longer outlive the credential that authorized
2981
+ * it" was a no-op that read as a guarantee.
2982
+ *
2983
+ * A reporter found it by expecting black screens an hour after a deploy
2984
+ * and getting none. Their conclusion is the one to keep: the guarantee was
2985
+ * not false, it was scoped to an auth shape the sentence did not name.
2986
+ *
2987
+ * Optional, and absent still means no bound — the failure direction is the
2988
+ * behaviour that already existed.
2989
+ */
2990
+ readonly credentialExpiresAt?: number;
2870
2991
  } | {
2871
2992
  readonly kind: 'skip';
2872
2993
  } | {
@@ -2890,6 +3011,10 @@ export declare interface StreamProcedureDescriptor<Name extends string, Input ex
2890
3011
  /** True when the stream is kept OFF the wire — no client-group entry and no
2891
3012
  * route in dev or serve. See `internal` on the definer's options. */
2892
3013
  readonly internal: boolean | undefined;
3014
+ /** WHO MAY LISTEN. Checked at subscribe AND re-checked before every element,
3015
+ * the same as a query's — a stream is a long-lived grant and the scopes that
3016
+ * justified it can be withdrawn while it is still open. */
3017
+ readonly guards: Guards | undefined;
2893
3018
  }
2894
3019
 
2895
3020
  export declare const streamToRpc: <Name extends string, Input extends Schema.Schema.Any, Element extends Schema.Schema.Any, Err extends Schema.Schema.All>(descriptor: StreamProcedureDescriptor<Name, Input, Element, Err>, extraErrors?: ExtraErrors) => Rpc.Rpc<Name, Input extends Schema.Struct.Fields ? Schema.Struct<Input> : Input, Stream<Element, Schema.Schema.All>, typeof Schema.Never, never>;
@@ -2926,6 +3051,20 @@ export declare const Subject: Schema.Union<[Schema.Struct<{
2926
3051
 
2927
3052
  export declare type Subject = typeof Subject.Type;
2928
3053
 
3054
+ /** A strategy's verdict on a request.
3055
+ * - `matched`: this strategy claims the request; here's the Subject.
3056
+ * - `skip`: not my request (e.g. cookie absent); try next strategy.
3057
+ * - `failed`: this IS my request BUT validation failed (signature
3058
+ * mismatch, expired JWT). Composer bails to anonymous + logs;
3059
+ * silently falling through would mask attacks. */
3060
+ /** What the composed chain returns: the subject, plus what it learned about the
3061
+ * credential's lifetime on the way. */
3062
+ export declare interface SubjectResolution {
3063
+ readonly subject: Subject;
3064
+ /** Unix SECONDS, when the matching strategy could tell. */
3065
+ readonly credentialExpiresAt?: number;
3066
+ }
3067
+
2929
3068
  /** The subject's scopes (empty for anonymous / unscoped). */
2930
3069
  export declare const subjectScopes: (subject: Subject) => ReadonlyArray<string>;
2931
3070
 
@@ -2934,36 +3073,36 @@ export declare class SubjectService extends SubjectService_base {
2934
3073
 
2935
3074
  declare const SubjectService_base: Context.TagClass<SubjectService, "@voltro/Subject", {
2936
3075
  readonly id: string;
2937
- readonly tenantId: string;
2938
3076
  readonly type: "user";
3077
+ readonly tenantId: string;
2939
3078
  readonly scopes?: readonly string[] | undefined;
2940
3079
  readonly metadata?: {
2941
3080
  readonly [x: string]: unknown;
2942
3081
  } | undefined;
2943
3082
  } | {
2944
3083
  readonly id: string;
2945
- readonly tenantId: string;
2946
3084
  readonly type: "apiKey";
3085
+ readonly tenantId: string;
2947
3086
  readonly scopes?: readonly string[] | undefined;
2948
3087
  readonly metadata?: {
2949
3088
  readonly [x: string]: unknown;
2950
3089
  } | undefined;
2951
3090
  } | {
2952
3091
  readonly id: string;
2953
- readonly tenantId: string;
2954
3092
  readonly type: "serviceAccount";
3093
+ readonly tenantId: string;
2955
3094
  readonly scopes?: readonly string[] | undefined;
2956
3095
  readonly metadata?: {
2957
3096
  readonly [x: string]: unknown;
2958
3097
  } | undefined;
2959
3098
  } | {
2960
3099
  readonly id: null;
2961
- readonly tenantId: string | null;
2962
3100
  readonly type: "anonymous";
3101
+ readonly tenantId: string | null;
2963
3102
  } | {
2964
3103
  readonly id: string;
2965
- readonly tenantId: null;
2966
3104
  readonly type: "system";
3105
+ readonly tenantId: null;
2967
3106
  readonly scopes?: readonly string[] | undefined;
2968
3107
  readonly metadata?: {
2969
3108
  readonly [x: string]: unknown;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { C as e, D as t, E as n, O as r, S as i, T as a, _ as o, a as ee, b as te, c as ne, d as re, f as ie, g as ae, h as oe, i as se, l as ce, m as le, n as ue, o as de, p as s, r as fe, s as pe, t as me, u as he, v as ge, w as _e, x as ve, y as ye } from "./serverErrorBus-C3JTqgIc.js";
2
- import { a as be, c as xe, d as Se, f as Ce, i as we, l as Te, n as Ee, o as De, p as Oe, r as ke, s as Ae, t as je, u as Me } from "./auth-BPdyOBsd.js";
2
+ import { a as be, c as xe, d as Se, f as Ce, i as we, l as Te, n as Ee, o as De, p as Oe, r as ke, s as Ae, t as je, u as Me } from "./auth-CXMrvPyX.js";
3
3
  import { Context as Ne, Layer as Pe, Schema as c } from "effect";
4
4
  import { Rpc as l } from "@effect/rpc";
5
5
  //#region src/rowPatch.ts
@@ -77,10 +77,19 @@ var Fe = c.Union(c.String, c.Number), u = c.Record({
77
77
  error: c.Unknown,
78
78
  revision: c.optional(c.Number)
79
79
  })), Ve = (e) => e.internal !== !0, g = (e) => {
80
+ if (Array.isArray(e.guards) && e.guards.length === 0) throw Error(`${e.name}: \`guards: []\` is empty, so it enforces nothing — but it reads\n at the call site as if this procedure were protected. Omit the field for an
81
+ unguarded procedure, or list the scopes required to call it.`);
82
+ let t = typeof e.source == "string" ? [e.source] : Array.isArray(e.source) ? e.source : void 0;
83
+ if (t !== void 0 && (t.length === 0 || t.some((e) => String(e).trim() === ""))) throw Error(`${e.name}: \`source\` is empty, so this query declares reactivity and\n subscribes to nothing — it serves one snapshot and never updates again,
84
+ which is indistinguishable from "nothing changed". Name the table(s) it
85
+ reads, or omit \`source\` for a non-reactive query.`);
80
86
  if (e.internal !== !0) return e;
81
- let t = [e.publicApi === void 0 ? void 0 : "publicApi", e.exposeAsTool === void 0 ? void 0 : "exposeAsTool"].filter((e) => e !== void 0);
82
- if (t.length === 0) return e;
83
- throw Error(`${e.name}: \`internal: true\` cannot be combined with ${t.map((e) => `\`${e}\``).join(" or ")}. \`internal\` takes the procedure OFF the wire; those put it back ON a different one (${t.includes("publicApi") ? "a REST route" : "an agent tool"}), and that surface is projected without consulting the flag — so the procedure would be unreachable from your client and reachable from the internet. Drop \`internal: true\` if the wider surface is intended, or remove the ${t.join(" / ")} annotation if it is not.`);
87
+ if (e.overridesPlugin === !0) throw Error(`${e.name}: \`internal: true\` cannot be combined with \`overridesPlugin\`.\n The override REMOVES the plugin route, and an internal procedure is not
88
+ wire-reachable so the plugin's route would vanish with nothing callable
89
+ in its place, and callers would get a 404 for something that used to work.`);
90
+ let n = [e.publicApi === void 0 ? void 0 : "publicApi", e.exposeAsTool === void 0 ? void 0 : "exposeAsTool"].filter((e) => e !== void 0);
91
+ if (n.length === 0) return e;
92
+ throw Error(`${e.name}: \`internal: true\` cannot be combined with ${n.map((e) => `\`${e}\``).join(" or ")}. \`internal\` takes the procedure OFF the wire; those put it back ON a different one (${n.includes("publicApi") ? "a REST route" : "an agent tool"}), and that surface is projected without consulting the flag — so the procedure would be unreachable from your client and reachable from the internet. Drop \`internal: true\` if the wider surface is intended, or remove the ${n.join(" / ")} annotation if it is not.`);
84
93
  }, He = (e) => e.action !== void 0 && e.resourceType !== void 0, _ = (e) => g({
85
94
  kind: "query",
86
95
  name: e.name,
@@ -92,7 +101,8 @@ var Fe = c.Union(c.String, c.Number), u = c.Record({
92
101
  guards: e.guards,
93
102
  publicApi: e.publicApi,
94
103
  exposeAsTool: e.exposeAsTool,
95
- internal: e.internal
104
+ internal: e.internal,
105
+ overridesPlugin: e.overridesPlugin
96
106
  }), v = (e) => g({
97
107
  kind: "mutation",
98
108
  name: e.name,
@@ -103,7 +113,8 @@ var Fe = c.Union(c.String, c.Number), u = c.Record({
103
113
  guards: e.guards,
104
114
  publicApi: e.publicApi,
105
115
  exposeAsTool: e.exposeAsTool,
106
- internal: e.internal
116
+ internal: e.internal,
117
+ overridesPlugin: e.overridesPlugin
107
118
  }), y = (e) => g({
108
119
  kind: "action",
109
120
  name: e.name,
@@ -115,14 +126,17 @@ var Fe = c.Union(c.String, c.Number), u = c.Record({
115
126
  target: e.target,
116
127
  publicApi: e.publicApi,
117
128
  exposeAsTool: e.exposeAsTool,
118
- internal: e.internal
129
+ internal: e.internal,
130
+ overridesPlugin: e.overridesPlugin
119
131
  }), Ue = (e) => g({
120
132
  kind: "stream",
121
133
  name: e.name,
122
134
  input: e.input,
123
135
  element: e.element,
124
136
  error: e.error ?? c.Never,
125
- internal: e.internal
137
+ internal: e.internal,
138
+ overridesPlugin: e.overridesPlugin,
139
+ guards: e.guards
126
140
  }), b = (e, t) => t && t.length > 0 ? c.Union(e, ...t) : e, x = (e, t) => t && t.length > 0 ? c.Union(e, s) : e, S = (e, t) => l.make(e.name, {
127
141
  payload: e.input,
128
142
  success: h(e.output),
@@ -195,6 +209,15 @@ var Fe = c.Union(c.String, c.Number), u = c.Record({
195
209
  };
196
210
  }, Xe = 7500, Ze = (e) => typeof e == "object" && !!e && e.kind === "event", Qe = (e) => {
197
211
  if (e.name.length === 0) throw Error("defineEvent: `name` must not be empty");
212
+ if (/\s/.test(e.name)) throw Error(`defineEvent("${e.name}"): \`name\` must not contain whitespace.\n The name is used as a broker subject segment. NATS refuses a subject with
213
+ whitespace and delivers nothing — silently, and only on that broker, so an
214
+ app that works on Redis stops working when the transport changes.
215
+ Use a dot for namespacing: "orders.paid".`);
216
+ if (e.guards !== void 0 && e.guards.length === 0) throw Error(`defineEvent("${e.name}"): \`guards: []\` is empty, so it enforces nothing —\n but it reads at the call site as if the event were protected. Omit the field
217
+ for an unguarded event, or list the scopes that may subscribe.`);
218
+ if (e.webhook?.rateLimit !== void 0 && e.webhook.rateLimit.perMinute < 1) throw Error(`defineEvent("${e.name}"): \`webhook.rateLimit.perMinute\` is ${e.webhook.rateLimit.perMinute}, which defers every delivery forever.\n There is no "unlimited" spelling here — omit \`rateLimit\` for no ceiling.`);
219
+ if (e.webhook?.version !== void 0 && e.webhook.version < 1) throw Error(`defineEvent("${e.name}"): \`webhook.version\` must be >= 1 (default 1).\n A subscriber pinned to 1 would read this event as BEHIND its own version,
220
+ which is the opposite of what a version bump is for.`);
198
221
  if (e.delivery === "latest" && e.webhook !== void 0) throw Error(`defineEvent(${e.name}): \`delivery: 'latest'\` cannot be combined with \`webhook\`.\n \`latest\` means a superseded delivery did not matter — but a webhook delivery is a
199
222
  durable side effect at a third party, and one already sent cannot be superseded.
200
223
  A high-rate event with an HTTP audience is also 60 deliveries per second per target;
package/dist/jwt.d.ts CHANGED
@@ -231,15 +231,28 @@ export declare const _resetJwksCache: () => void;
231
231
  /** Test-only: clear the OIDC discovery-document cache between runs. */
232
232
  export declare const _resetOidcDiscoveryCache: () => void;
233
233
 
234
- /** A strategy's verdict on a request.
235
- * - `matched`: this strategy claims the request; here's the Subject.
236
- * - `skip`: not my request (e.g. cookie absent); try next strategy.
237
- * - `failed`: this IS my request BUT validation failed (signature
238
- * mismatch, expired JWT). Composer bails to anonymous + logs;
239
- * silently falling through would mask attacks. */
240
234
  declare type StrategyResolution = {
241
235
  readonly kind: 'matched';
242
236
  readonly subject: Subject;
237
+ /**
238
+ * When this credential expires, in unix SECONDS — if the strategy knows.
239
+ *
240
+ * The strategy is the ONLY place in the system that has verified the token
241
+ * and holds its `exp`, and until now it could not say so. The credential
242
+ * bound therefore read one source: the `voltro:session` cookie. For an app
243
+ * authenticating with Bearer JWTs — six of our own catalog strategies do,
244
+ * and every one of them verifies an `exp` — it was silently `undefined`,
245
+ * so "a subscription can no longer outlive the credential that authorized
246
+ * it" was a no-op that read as a guarantee.
247
+ *
248
+ * A reporter found it by expecting black screens an hour after a deploy
249
+ * and getting none. Their conclusion is the one to keep: the guarantee was
250
+ * not false, it was scoped to an auth shape the sentence did not name.
251
+ *
252
+ * Optional, and absent still means no bound — the failure direction is the
253
+ * behaviour that already existed.
254
+ */
255
+ readonly credentialExpiresAt?: number;
243
256
  } | {
244
257
  readonly kind: 'skip';
245
258
  } | {
package/dist/jwt.js CHANGED
@@ -110,22 +110,28 @@ var i = 3600 * 1e3, a = /* @__PURE__ */ new Map(), o = (e, n) => {
110
110
  reason: `${e.id} token has no tenant claim and no defaultTenantId configured`
111
111
  };
112
112
  let i = e.subjectIdFromClaims ? e.subjectIdFromClaims(t) : String(t.sub ?? "");
113
- return i ? {
114
- kind: "matched",
115
- subject: {
116
- type: "user",
117
- id: i,
118
- tenantId: n,
119
- ...e.scopesFromClaims === void 0 ? {} : { scopes: e.scopesFromClaims(t) },
120
- metadata: {
121
- provider: e.id,
122
- claims: t
123
- }
124
- }
125
- } : {
113
+ if (!i) return {
126
114
  kind: "failed",
127
115
  reason: `${e.id} token has no sub claim`
128
116
  };
117
+ let a = {
118
+ type: "user",
119
+ id: i,
120
+ tenantId: n,
121
+ ...e.scopesFromClaims === void 0 ? {} : { scopes: e.scopesFromClaims(t) },
122
+ metadata: {
123
+ provider: e.id,
124
+ claims: t
125
+ }
126
+ }, o = typeof t.exp == "number" && Number.isFinite(t.exp) ? t.exp : void 0;
127
+ return o === void 0 ? {
128
+ kind: "matched",
129
+ subject: a
130
+ } : {
131
+ kind: "matched",
132
+ subject: a,
133
+ credentialExpiresAt: o
134
+ };
129
135
  } catch (t) {
130
136
  return t instanceof c ? {
131
137
  kind: "failed",
package/dist/rest.d.ts CHANGED
@@ -31,6 +31,28 @@ declare interface ActionProcedureDescriptor<Name extends string, Input extends S
31
31
  readonly exposeAsTool: ExposeAsTool | undefined;
32
32
  /** True when the procedure is kept OFF the wire — no client-group entry and no
33
33
  * route in dev or serve. See `internal` on the definer's options. */
34
+ /**
35
+ * Replace a PLUGIN route that answers to this same tag.
36
+ *
37
+ * Without it, a user route and a plugin route sharing a tag is a hard error,
38
+ * and correctly so — two handlers behind one name is not a thing a caller can
39
+ * reason about. But refusing is the wrong answer when the app deliberately
40
+ * wants its own version: the two escapes available otherwise are to rename
41
+ * your procedure (so the split runs along "who built it" rather than along a
42
+ * domain boundary) or to `alias` the whole plugin away (same, one level up).
43
+ * For a frontend developer that is the worst possible partition.
44
+ *
45
+ * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose
46
+ * surface is richer than theirs, add `archive`/`unarchive` beside it — which
47
+ * already composes, since the collision check compares FULL tags and not
48
+ * prefixes — and replace `markRead`, because theirs maintains archive state.
49
+ *
50
+ * Explicit, never inferred. Silently letting the app win would mean a plugin
51
+ * upgrade that adds a route could shadow an app procedure with no diff to
52
+ * read; declaring it makes the intent reviewable and puts the override in the
53
+ * file that performs it.
54
+ */
55
+ readonly overridesPlugin: boolean | undefined;
34
56
  readonly internal: boolean | undefined;
35
57
  }
36
58
 
@@ -209,6 +231,28 @@ declare interface MutationProcedureDescriptor<Name extends string, Input extends
209
231
  readonly exposeAsTool: ExposeAsTool | undefined;
210
232
  /** True when the procedure is kept OFF the wire — no client-group entry and no
211
233
  * route in dev or serve. See `internal` on the definer's options. */
234
+ /**
235
+ * Replace a PLUGIN route that answers to this same tag.
236
+ *
237
+ * Without it, a user route and a plugin route sharing a tag is a hard error,
238
+ * and correctly so — two handlers behind one name is not a thing a caller can
239
+ * reason about. But refusing is the wrong answer when the app deliberately
240
+ * wants its own version: the two escapes available otherwise are to rename
241
+ * your procedure (so the split runs along "who built it" rather than along a
242
+ * domain boundary) or to `alias` the whole plugin away (same, one level up).
243
+ * For a frontend developer that is the worst possible partition.
244
+ *
245
+ * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose
246
+ * surface is richer than theirs, add `archive`/`unarchive` beside it — which
247
+ * already composes, since the collision check compares FULL tags and not
248
+ * prefixes — and replace `markRead`, because theirs maintains archive state.
249
+ *
250
+ * Explicit, never inferred. Silently letting the app win would mean a plugin
251
+ * upgrade that adds a route could shadow an app procedure with no diff to
252
+ * read; declaring it makes the intent reviewable and puts the override in the
253
+ * file that performs it.
254
+ */
255
+ readonly overridesPlugin: boolean | undefined;
212
256
  readonly internal: boolean | undefined;
213
257
  }
214
258
 
@@ -517,6 +561,28 @@ declare interface QueryProcedureDescriptor<Name extends string, Input extends Sc
517
561
  readonly exposeAsTool: ExposeAsTool | undefined;
518
562
  /** True when the procedure is kept OFF the wire — no client-group entry and no
519
563
  * route in dev or serve. See `internal` on the definer's options. */
564
+ /**
565
+ * Replace a PLUGIN route that answers to this same tag.
566
+ *
567
+ * Without it, a user route and a plugin route sharing a tag is a hard error,
568
+ * and correctly so — two handlers behind one name is not a thing a caller can
569
+ * reason about. But refusing is the wrong answer when the app deliberately
570
+ * wants its own version: the two escapes available otherwise are to rename
571
+ * your procedure (so the split runs along "who built it" rather than along a
572
+ * domain boundary) or to `alias` the whole plugin away (same, one level up).
573
+ * For a frontend developer that is the worst possible partition.
574
+ *
575
+ * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose
576
+ * surface is richer than theirs, add `archive`/`unarchive` beside it — which
577
+ * already composes, since the collision check compares FULL tags and not
578
+ * prefixes — and replace `markRead`, because theirs maintains archive state.
579
+ *
580
+ * Explicit, never inferred. Silently letting the app win would mean a plugin
581
+ * upgrade that adds a route could shadow an app procedure with no diff to
582
+ * read; declaring it makes the intent reviewable and puts the override in the
583
+ * file that performs it.
584
+ */
585
+ readonly overridesPlugin: boolean | undefined;
520
586
  readonly internal: boolean | undefined;
521
587
  }
522
588
 
package/dist/rest.js CHANGED
@@ -1,5 +1,5 @@
1
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-BPdyOBsd.js";
2
+ import { s as a } from "./auth-CXMrvPyX.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) => {
package/dist/session.d.ts CHANGED
@@ -108,6 +108,33 @@ export declare const resolveSessionSecret: () => string;
108
108
  */
109
109
  export declare const resolveSessionSecrets: () => SessionSecrets;
110
110
 
111
+ /**
112
+ * The cookie the framework's own session strategy writes and reads.
113
+ *
114
+ * Exported and single-sourced because two readers now need it — the auth
115
+ * strategy in `dev.ts` and `sessionExpiryFromHeaders` below. Two copies of one
116
+ * env expression is the "derived twice" shape: both sites look correct and
117
+ * they disagree the moment someone sets the variable.
118
+ */
119
+ export declare const SESSION_COOKIE_NAME: string;
120
+
121
+ /**
122
+ * 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.
124
+ *
125
+ * It VERIFIES rather than decoding. An unverified read would be worse than
126
+ * nothing here: the value bounds how long a subscription may live, so a client
127
+ * that could forge a far-future `exp` would lift exactly the ceiling this
128
+ * exists to impose. The cost is one HMAC check on a call that already carries
129
+ * the cookie.
130
+ *
131
+ * A SHARED builder on purpose. `voltro dev` and `voltro serve` assemble their
132
+ * middleware independently, and a value derived twice is the shape this repo
133
+ * has been bitten by — dev and serve agreeing on a field while disagreeing on
134
+ * what it contains. Both call this.
135
+ */
136
+ export declare const sessionExpiryFromHeaders: (headers: Record<string, string | undefined>) => number | undefined;
137
+
111
138
  export declare type SessionPayload = typeof SessionPayload_2.Type;
112
139
 
113
140
  declare const SessionPayload_2: Schema.Struct<{