@vxil/feature-configs 0.10.0 → 0.11.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/hooks.d.ts CHANGED
@@ -10,12 +10,16 @@ export declare const HOOK_LIMITS: {
10
10
  readonly maxReadHooksPerCollection: 10;
11
11
  };
12
12
  /** The ONLY root variables an expression may reference. `caller` is the VERIFIED
13
- * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only and
14
- * populated ONLY on the READ path (runReadHooks); on the write path and in
15
- * server-caller mode it is null, exactly like `before` on a create. It carries
16
- * `caller.endUserId` (the verified session sub, or null) and `caller.principal`
17
- * ('end_user' | 'tenant'). It is the missing "caller/session context" a read
18
- * hook keys on to redact/derive per-viewer. */
13
+ * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only.
14
+ * It carries `caller.endUserId` (the verified session sub, or null),
15
+ * `caller.principal` ('end_user' | 'tenant') and `caller.roles` (the VERIFIED
16
+ * session role claims of the signed-in session — never a body or header
17
+ * value; null in server-caller mode). READ hooks always get it
18
+ * (server mode: `{ endUserId: null, principal: 'tenant', roles: null }`).
19
+ * WRITE hooks get it in verified end-user mode only; on a
20
+ * server write it is null, exactly like `before` on a create — so a role rule
21
+ * reads `isNull(caller) || contains(caller.roles, 'manager')`. `contains` on
22
+ * an array is exact element membership. */
19
23
  export declare const HOOK_ROOT_VARS: readonly ["item", "before", "now", "caller"];
20
24
  /** The ONLY callable functions. Each is pure + deterministic + bounded. */
21
25
  export declare const HOOK_FUNCTIONS: readonly ["min", "max", "abs", "round", "floor", "ceil", "sqrt", "pow", "sign", "len", "lower", "upper", "trim", "substr", "contains", "startsWith", "endsWith", "concat", "coalesce", "ifNull", "not", "isNull", "number", "string", "bool", "daysBetween", "yearsBetween"];
@@ -80,20 +84,25 @@ export declare function parseExpr(src: string): Node;
80
84
  /** Walk the AST and reject anything outside the allow-list, plus depth bounds.
81
85
  * Returns an array of human-readable errors (empty = safe). Pure, no eval. */
82
86
  export declare function validateAst(root: Node): string[];
83
- /** The VERIFIED caller/session context for read hooks (guide ch. 7). Read-only;
84
- * present only on the read path in end-user mode. In server mode / write path
85
- * the whole object is null. */
87
+ /** The VERIFIED caller/session context for Lane-A hooks (guide ch. 7).
88
+ * Read-only. Read hooks always get it; write hooks get it in verified end-user
89
+ * mode only (a server write passes null — see HOOK_ROOT_VARS). */
86
90
  export interface HookCaller {
87
91
  /** the verified end-user session sub, or null in server-caller mode. */
88
92
  endUserId: string | null;
89
93
  /** 'end_user' when a session was verified at the edge, else 'tenant'. */
90
94
  principal: 'end_user' | 'tenant';
95
+ /** the VERIFIED session role claims of the signed-in session; `[]` for a
96
+ * session without roles, null in server-caller mode. Optional so an older
97
+ * caller object still reads
98
+ * as "no roles". */
99
+ roles?: readonly string[] | null;
91
100
  }
92
101
  export interface HookContext {
93
102
  item: Record<string, unknown>;
94
103
  before: Record<string, unknown> | null;
95
104
  now: string;
96
- /** verified caller/session context (read path only); null otherwise. */
105
+ /** verified caller/session context; null on a server write. */
97
106
  caller?: HookCaller | null;
98
107
  }
99
108
  /** A mutable step counter SHARED across many evalExpr calls (the read path's
@@ -127,7 +136,10 @@ export interface HookRunResult {
127
136
  * Mutates a CLONE: derive hooks set fields; a validate hook that returns falsy
128
137
  * throws HookRejection (→ the caller maps to a 422 and the tx rolls back).
129
138
  * Throws HookEvalError on a runtime fault (also a clean 422, never a 500). */
130
- export declare function runWriteHooks(hooks: Record<string, HookDef> | undefined, collection: string, phase: 'create' | 'update', data: Record<string, unknown>, before: Record<string, unknown> | null, now: string): HookRunResult;
139
+ export declare function runWriteHooks(hooks: Record<string, HookDef> | undefined, collection: string, phase: 'create' | 'update', data: Record<string, unknown>, before: Record<string, unknown> | null, now: string,
140
+ /** the VERIFIED end-user caller (endUserId + principal + roles) in
141
+ * end-user mode; omitted / null on a server write (expressions see null). */
142
+ caller?: HookCaller | null): HookRunResult;
131
143
  export interface ReadHookResult {
132
144
  /** Surviving rows in input order. Each is a SHALLOW COPY with a copied `data`
133
145
  * bag — the caller's rows are never mutated and nothing here is persisted. */
@@ -154,8 +166,8 @@ export interface ReadHookResult {
154
166
  * fire here, and read events never fire in runWriteHooks. */
155
167
  export declare function runReadHooks(hooks: Record<string, HookDef> | undefined, collection: string, rows: ReadonlyArray<Record<string, unknown>>, now: string,
156
168
  /** verified caller/session context (guide ch. 7) — read-only, exposed to
157
- * expressions as `caller.endUserId` / `caller.principal`. Omitted ⇒ null
158
- * (server-caller mode). */
169
+ * expressions as `caller.endUserId` / `caller.principal` / `caller.roles`.
170
+ * Omitted ⇒ null (server-caller mode). */
159
171
  caller?: HookCaller | null): ReadHookResult;
160
172
  export interface ExprRef {
161
173
  root: 'item' | 'before' | 'now' | 'caller';
package/dist/hooks.js CHANGED
@@ -61,12 +61,16 @@ export const HOOK_LIMITS = {
61
61
  };
62
62
  // ── allow-lists ─────────────────────────────────────────────────────────────
63
63
  /** The ONLY root variables an expression may reference. `caller` is the VERIFIED
64
- * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only and
65
- * populated ONLY on the READ path (runReadHooks); on the write path and in
66
- * server-caller mode it is null, exactly like `before` on a create. It carries
67
- * `caller.endUserId` (the verified session sub, or null) and `caller.principal`
68
- * ('end_user' | 'tenant'). It is the missing "caller/session context" a read
69
- * hook keys on to redact/derive per-viewer. */
64
+ * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only.
65
+ * It carries `caller.endUserId` (the verified session sub, or null),
66
+ * `caller.principal` ('end_user' | 'tenant') and `caller.roles` (the VERIFIED
67
+ * session role claims of the signed-in session — never a body or header
68
+ * value; null in server-caller mode). READ hooks always get it
69
+ * (server mode: `{ endUserId: null, principal: 'tenant', roles: null }`).
70
+ * WRITE hooks get it in verified end-user mode only; on a
71
+ * server write it is null, exactly like `before` on a create — so a role rule
72
+ * reads `isNull(caller) || contains(caller.roles, 'manager')`. `contains` on
73
+ * an array is exact element membership. */
70
74
  export const HOOK_ROOT_VARS = ['item', 'before', 'now', 'caller'];
71
75
  /** The ONLY callable functions. Each is pure + deterministic + bounded. */
72
76
  export const HOOK_FUNCTIONS = [
@@ -448,7 +452,16 @@ const FUNCS = {
448
452
  upper: (a) => str(a[0]).toUpperCase(),
449
453
  trim: (a) => str(a[0]).trim(),
450
454
  substr: (a) => { const s = str(a[0]); const start = Math.max(0, Math.trunc(num(a[1]))); const length = a[2] === undefined ? undefined : Math.max(0, Math.trunc(num(a[2]))); return s.substring(start, length === undefined ? undefined : start + length); },
451
- contains: (a) => str(a[0]).includes(str(a[1])),
455
+ // An ARRAY haystack is exact element membership (`contains(caller.roles,
456
+ // 'manager')` must never match 'man' or 'managers'); a scalar haystack keeps
457
+ // the substring test. Elements compare as strings; objects never match.
458
+ contains: (a) => {
459
+ const needle = str(a[1]);
460
+ if (Array.isArray(a[0])) {
461
+ return a[0].some((el) => el !== null && typeof el !== 'object' && String(el) === needle);
462
+ }
463
+ return str(a[0]).includes(needle);
464
+ },
452
465
  startsWith: (a) => str(a[0]).startsWith(str(a[1])),
453
466
  endsWith: (a) => str(a[0]).endsWith(str(a[1])),
454
467
  concat: (a) => { const out = a.map(str).join(''); if (out.length > HOOK_LIMITS.maxStringLen)
@@ -490,7 +503,7 @@ export function evalExpr(root, ctx, shared) {
490
503
  return ctx.before;
491
504
  if (nd.name === 'now')
492
505
  return ctx.now;
493
- // caller (read path only) — null on the write path / server mode.
506
+ // caller — null on a server write (and on any path that passes none).
494
507
  if (nd.name === 'caller')
495
508
  return ctx.caller ?? null;
496
509
  throw new HookEvalError(`unknown variable '${nd.name}'`);
@@ -658,12 +671,15 @@ function eventMatches(event, phase) {
658
671
  * Mutates a CLONE: derive hooks set fields; a validate hook that returns falsy
659
672
  * throws HookRejection (→ the caller maps to a 422 and the tx rolls back).
660
673
  * Throws HookEvalError on a runtime fault (also a clean 422, never a 500). */
661
- export function runWriteHooks(hooks, collection, phase, data, before, now) {
674
+ export function runWriteHooks(hooks, collection, phase, data, before, now,
675
+ /** the VERIFIED end-user caller (endUserId + principal + roles) in
676
+ * end-user mode; omitted / null on a server write (expressions see null). */
677
+ caller) {
662
678
  const out = { ...data };
663
679
  const derived = [];
664
680
  if (!hooks)
665
681
  return { data: out, derived };
666
- const ctx = { item: out, before, now };
682
+ const ctx = { item: out, before, now, caller: caller ?? null };
667
683
  const entries = Object.entries(hooks).filter(([, h]) => h.collection === collection && h.enabled !== false && eventMatches(h.event, phase));
668
684
  // pass 1: derives (so validates can read derived values); pass 2: validates.
669
685
  for (const [id, h] of entries) {
@@ -725,8 +741,8 @@ export function runWriteHooks(hooks, collection, phase, data, before, now) {
725
741
  * fire here, and read events never fire in runWriteHooks. */
726
742
  export function runReadHooks(hooks, collection, rows, now,
727
743
  /** verified caller/session context (guide ch. 7) — read-only, exposed to
728
- * expressions as `caller.endUserId` / `caller.principal`. Omitted ⇒ null
729
- * (server-caller mode). */
744
+ * expressions as `caller.endUserId` / `caller.principal` / `caller.roles`.
745
+ * Omitted ⇒ null (server-caller mode). */
730
746
  caller) {
731
747
  if (!hooks)
732
748
  return { rows: [...rows], dropped: 0 };
package/dist/index.d.ts CHANGED
@@ -218,6 +218,7 @@ declare const OidcProviderSchema: import("@sinclair/typebox").TObject<{
218
218
  email: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
219
219
  name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
220
220
  roles: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
221
+ rolePrefix: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
221
222
  }>>;
222
223
  allowedDomains: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
223
224
  autoLink: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
@@ -275,6 +276,7 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
275
276
  email: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
276
277
  name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
277
278
  roles: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
279
+ rolePrefix: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
278
280
  }>>;
279
281
  allowedDomains: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
280
282
  autoLink: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
@@ -319,6 +321,8 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
319
321
  breachedPasswords: import("@sinclair/typebox").TBoolean;
320
322
  captchaSecretRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
321
323
  allowedRedirectOrigins: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
324
+ inviteOnly: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
325
+ allowedEmailDomains: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
322
326
  }>>;
323
327
  }>;
324
328
  /** The security bag as persisted (present ⇒ leaf defaults applied). */
@@ -633,6 +637,7 @@ export declare const RagConfigSchema: import("@sinclair/typebox").TObject<{
633
637
  topK: import("@sinclair/typebox").TInteger;
634
638
  mode: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"hybrid">, import("@sinclair/typebox").TLiteral<"vector">, import("@sinclair/typebox").TLiteral<"keyword">]>;
635
639
  minScore: import("@sinclair/typebox").TNumber;
640
+ minSimilarity: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TNumber>;
636
641
  rerank: import("@sinclair/typebox").TBoolean;
637
642
  }>;
638
643
  boosts: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TObject<{
package/dist/index.js CHANGED
@@ -463,6 +463,15 @@ const OidcProviderSchema = Type.Object({
463
463
  email: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
464
464
  name: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
465
465
  roles: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
466
+ // G-38b: an optional namespace prepended to every mapped IdP role (e.g.
467
+ // `idp-`), so an IdP group named like an app role (`finance`, `owner`)
468
+ // arrives as `idp-finance` and cannot pass a `readRoles: ['finance']`
469
+ // gate. Absent = the raw group names (the shipped behaviour). The prefixed
470
+ // role still obeys the 32-char role bound (longer ones are dropped).
471
+ // The alphabet is the cms readRoles / orgs role slug alphabet
472
+ // (`^[a-z0-9][a-z0-9_-]*$`, cms-v1 READ_ROLE_RE), so a prefixed role can
473
+ // itself be named in a gate — a `:`/`.`/uppercase prefix never could.
474
+ rolePrefix: Type.Optional(Type.String({ minLength: 1, maxLength: 16, pattern: '^[a-z0-9][a-z0-9_-]{0,15}$' })),
466
475
  })),
467
476
  // when non-empty, the asserted email's domain MUST be listed (fail-closed:
468
477
  // no email ⇒ refused) → 403 oidc_domain_not_allowed; a listed domain also
@@ -667,6 +676,23 @@ export const AuthConfigSchema = Type.Object({
667
676
  breachedPasswords: Type.Boolean({ default: false }),
668
677
  captchaSecretRef: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
669
678
  allowedRedirectOrigins: Type.Optional(Type.Array(Type.String({ minLength: 8, maxLength: 253, pattern: REDIRECT_ORIGIN_PATTERN }), { maxItems: 32 })),
679
+ // G-3 sign-up gating (2026-10-05). Both Optional with NO default, so an
680
+ // existing manifest folds byte-identically and the gate is opt-in. When
681
+ // EITHER is set, every self-service account-create path (password sign-up,
682
+ // magic-link / OTP pre-register, the guest → email claim, social sign-in
683
+ // with google/github/apple/facebook) is checked, fail-closed:
684
+ // • inviteOnly — a new account only for an address the tenant already
685
+ // put in its user registry server-side (POST /v1/users, an import);
686
+ // • allowedEmailDomains — otherwise, only for an address at one of these
687
+ // domains (exact, lowercase; a provider must assert the address
688
+ // VERIFIED).
689
+ // With either set, password sign-up is refused (it proves nothing about
690
+ // the address — a stranger could squat an invitee's address), guest
691
+ // sign-in is refused (a guest is nobody's invitee), and an existing
692
+ // account is never affected. The generic OIDC issuer keeps its own gate
693
+ // (providers.oidc.allowedDomains + IdP assignment). auth-v1 signupGate.ts.
694
+ inviteOnly: Type.Optional(Type.Boolean()),
695
+ allowedEmailDomains: Type.Optional(Type.Array(Type.String({ minLength: 3, maxLength: 253, pattern: '^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$' }), { maxItems: 32 })),
670
696
  })),
671
697
  });
672
698
  // ── DECLARED API STATE (2026-09-23) ──────────────────────────────────────────
@@ -1185,6 +1211,12 @@ export const RagConfigSchema = Type.Object({
1185
1211
  mode: Type.Union([Type.Literal('hybrid'), Type.Literal('vector'), Type.Literal('keyword')], { default: 'hybrid' }),
1186
1212
  // drop retrieved chunks below this score BEFORE budgeting (0 = keep all).
1187
1213
  minScore: Type.Number({ default: 0 }),
1214
+ // drop retrieved chunks whose cosine `similarity` to the query is
1215
+ // below this floor (-1..1). Unlike minScore (a floor on the RRF rank
1216
+ // value, ~0.016-0.033 at the top) this is the relevance threshold a RAG
1217
+ // app means. Absent = no floor; a hit with no measured similarity (no
1218
+ // query vector: keyword mode) is never dropped by it.
1219
+ minSimilarity: Type.Optional(Type.Number({ minimum: -1, maximum: 1 })),
1188
1220
  // forward rerank:true to vector-search's post-RRF BYO rerank pass (it owns
1189
1221
  // the provider + key); inert until that pass exists/is configured there.
1190
1222
  rerank: Type.Boolean({ default: false }),
@@ -35,4 +35,22 @@ interface CdcRuleLike {
35
35
  }
36
36
  /** Cross-field rule for CmsConfig.cdc (called from validateFeatureConfig). */
37
37
  export declare function validateCdcConfig(cdc: Record<string, CdcRuleLike> | undefined): string[];
38
+ /** What a payload:'full' change-data rule must know about its target
39
+ * collection's read gates. `readRoleFields`: fields with a non-empty
40
+ * `readRoles`; `endUserAccess`: the collection's end-user access mode. */
41
+ export interface CdcCollectionGates {
42
+ readRoleFields?: readonly string[];
43
+ endUserAccess?: 'readwrite' | 'read' | 'none';
44
+ }
45
+ /**
46
+ * A `payload: 'full'` change-data rule inlines the stored row into a realtime
47
+ * frame that every subscriber of the channel receives — it does not go through
48
+ * the read projection. So it must never target a collection whose reads are
49
+ * gated: a field with `readRoles` (the frame would carry the gated value), or
50
+ * an `endUserAccess` of 'read' / 'none' (the frame would bypass the
51
+ * collection's end-user gate). Pure; one problem line per offending ENABLED
52
+ * rule (an unknown collection is skipped). The safe shape is `payload: 'ids'`
53
+ * and a re-read through the cms, where every gate applies.
54
+ */
55
+ export declare function cdcFullFramesOverGatedCollections(cdc: unknown, collections: Record<string, CdcCollectionGates>): string[];
38
56
  export {};
@@ -227,3 +227,35 @@ export function validateCdcConfig(cdc) {
227
227
  }
228
228
  return errs;
229
229
  }
230
+ /**
231
+ * A `payload: 'full'` change-data rule inlines the stored row into a realtime
232
+ * frame that every subscriber of the channel receives — it does not go through
233
+ * the read projection. So it must never target a collection whose reads are
234
+ * gated: a field with `readRoles` (the frame would carry the gated value), or
235
+ * an `endUserAccess` of 'read' / 'none' (the frame would bypass the
236
+ * collection's end-user gate). Pure; one problem line per offending ENABLED
237
+ * rule (an unknown collection is skipped). The safe shape is `payload: 'ids'`
238
+ * and a re-read through the cms, where every gate applies.
239
+ */
240
+ export function cdcFullFramesOverGatedCollections(cdc, collections) {
241
+ if (!cdc || typeof cdc !== 'object' || Array.isArray(cdc))
242
+ return [];
243
+ const problems = [];
244
+ for (const [name, raw] of Object.entries(cdc)) {
245
+ const r = raw;
246
+ if (!r || r.payload !== 'full' || r.enabled === false || typeof r.collection !== 'string')
247
+ continue;
248
+ const g = Object.prototype.hasOwnProperty.call(collections, r.collection) ? collections[r.collection] : undefined;
249
+ if (!g)
250
+ continue;
251
+ const gated = (g.readRoleFields ?? []).filter((f) => typeof f === 'string');
252
+ const access = g.endUserAccess ?? 'readwrite';
253
+ if (gated.length > 0) {
254
+ problems.push(`cdc '${name}' publishes full '${r.collection}' rows, but field(s) ${gated.join(', ')} declare readRoles — every subscriber would receive them; use payload: 'ids' and re-read`);
255
+ }
256
+ else if (access !== 'readwrite') {
257
+ problems.push(`cdc '${name}' publishes full '${r.collection}' rows, but the collection's endUserAccess is '${access}' — a full frame would bypass that gate for every subscriber; use payload: 'ids' and re-read`);
258
+ }
259
+ }
260
+ return problems;
261
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/feature-configs",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "The per-feature configuration schemas and validators behind vxil.config.ts (published for @vxil/cli and @vxil/config).",
5
5
  "license": "MIT",
6
6
  "homepage": "https://vxil.com",
package/src/hooks.ts CHANGED
@@ -63,12 +63,16 @@ export const HOOK_LIMITS = {
63
63
 
64
64
  // ── allow-lists ─────────────────────────────────────────────────────────────
65
65
  /** The ONLY root variables an expression may reference. `caller` is the VERIFIED
66
- * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only and
67
- * populated ONLY on the READ path (runReadHooks); on the write path and in
68
- * server-caller mode it is null, exactly like `before` on a create. It carries
69
- * `caller.endUserId` (the verified session sub, or null) and `caller.principal`
70
- * ('end_user' | 'tenant'). It is the missing "caller/session context" a read
71
- * hook keys on to redact/derive per-viewer. */
66
+ * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only.
67
+ * It carries `caller.endUserId` (the verified session sub, or null),
68
+ * `caller.principal` ('end_user' | 'tenant') and `caller.roles` (the VERIFIED
69
+ * session role claims of the signed-in session — never a body or header
70
+ * value; null in server-caller mode). READ hooks always get it
71
+ * (server mode: `{ endUserId: null, principal: 'tenant', roles: null }`).
72
+ * WRITE hooks get it in verified end-user mode only; on a
73
+ * server write it is null, exactly like `before` on a create — so a role rule
74
+ * reads `isNull(caller) || contains(caller.roles, 'manager')`. `contains` on
75
+ * an array is exact element membership. */
72
76
  export const HOOK_ROOT_VARS = ['item', 'before', 'now', 'caller'] as const;
73
77
  /** The ONLY callable functions. Each is pure + deterministic + bounded. */
74
78
  export const HOOK_FUNCTIONS = [
@@ -345,21 +349,26 @@ export function validateAst(root: Node): string[] {
345
349
  }
346
350
 
347
351
  // ── evaluator (bounded, deterministic interpreter) ──────────────────────────
348
- /** The VERIFIED caller/session context for read hooks (guide ch. 7). Read-only;
349
- * present only on the read path in end-user mode. In server mode / write path
350
- * the whole object is null. */
352
+ /** The VERIFIED caller/session context for Lane-A hooks (guide ch. 7).
353
+ * Read-only. Read hooks always get it; write hooks get it in verified end-user
354
+ * mode only (a server write passes null — see HOOK_ROOT_VARS). */
351
355
  export interface HookCaller {
352
356
  /** the verified end-user session sub, or null in server-caller mode. */
353
357
  endUserId: string | null;
354
358
  /** 'end_user' when a session was verified at the edge, else 'tenant'. */
355
359
  principal: 'end_user' | 'tenant';
360
+ /** the VERIFIED session role claims of the signed-in session; `[]` for a
361
+ * session without roles, null in server-caller mode. Optional so an older
362
+ * caller object still reads
363
+ * as "no roles". */
364
+ roles?: readonly string[] | null;
356
365
  }
357
366
 
358
367
  export interface HookContext {
359
368
  item: Record<string, unknown>;
360
369
  before: Record<string, unknown> | null;
361
370
  now: string; // injected ISO timestamp — the only ambient input
362
- /** verified caller/session context (read path only); null otherwise. */
371
+ /** verified caller/session context; null on a server write. */
363
372
  caller?: HookCaller | null;
364
373
  }
365
374
 
@@ -414,7 +423,16 @@ const FUNCS: Record<string, (a: unknown[]) => unknown> = {
414
423
  upper: (a) => str(a[0]).toUpperCase(),
415
424
  trim: (a) => str(a[0]).trim(),
416
425
  substr: (a) => { const s = str(a[0]); const start = Math.max(0, Math.trunc(num(a[1]))); const length = a[2] === undefined ? undefined : Math.max(0, Math.trunc(num(a[2]))); return s.substring(start, length === undefined ? undefined : start + length); },
417
- contains: (a) => str(a[0]).includes(str(a[1])),
426
+ // An ARRAY haystack is exact element membership (`contains(caller.roles,
427
+ // 'manager')` must never match 'man' or 'managers'); a scalar haystack keeps
428
+ // the substring test. Elements compare as strings; objects never match.
429
+ contains: (a) => {
430
+ const needle = str(a[1]);
431
+ if (Array.isArray(a[0])) {
432
+ return a[0].some((el) => el !== null && typeof el !== 'object' && String(el) === needle);
433
+ }
434
+ return str(a[0]).includes(needle);
435
+ },
418
436
  startsWith: (a) => str(a[0]).startsWith(str(a[1])),
419
437
  endsWith: (a) => str(a[0]).endsWith(str(a[1])),
420
438
  concat: (a) => { const out = a.map(str).join(''); if (out.length > HOOK_LIMITS.maxStringLen) throw new HookEvalError('concat result too long'); return out; },
@@ -454,7 +472,7 @@ export function evalExpr(root: Node, ctx: HookContext, shared?: EvalBudget): unk
454
472
  if (nd.name === 'item') return ctx.item;
455
473
  if (nd.name === 'before') return ctx.before;
456
474
  if (nd.name === 'now') return ctx.now;
457
- // caller (read path only) — null on the write path / server mode.
475
+ // caller — null on a server write (and on any path that passes none).
458
476
  if (nd.name === 'caller') return ctx.caller ?? null;
459
477
  throw new HookEvalError(`unknown variable '${nd.name}'`);
460
478
  case 'member': return ownGet(ev(nd.obj), nd.prop);
@@ -608,11 +626,14 @@ export function runWriteHooks(
608
626
  data: Record<string, unknown>,
609
627
  before: Record<string, unknown> | null,
610
628
  now: string,
629
+ /** the VERIFIED end-user caller (endUserId + principal + roles) in
630
+ * end-user mode; omitted / null on a server write (expressions see null). */
631
+ caller?: HookCaller | null,
611
632
  ): HookRunResult {
612
633
  const out: Record<string, unknown> = { ...data };
613
634
  const derived: string[] = [];
614
635
  if (!hooks) return { data: out, derived };
615
- const ctx: HookContext = { item: out, before, now };
636
+ const ctx: HookContext = { item: out, before, now, caller: caller ?? null };
616
637
  const entries = Object.entries(hooks).filter(
617
638
  ([, h]) => h.collection === collection && h.enabled !== false && eventMatches(h.event, phase),
618
639
  );
@@ -687,8 +708,8 @@ export function runReadHooks(
687
708
  rows: ReadonlyArray<Record<string, unknown>>,
688
709
  now: string,
689
710
  /** verified caller/session context (guide ch. 7) — read-only, exposed to
690
- * expressions as `caller.endUserId` / `caller.principal`. Omitted ⇒ null
691
- * (server-caller mode). */
711
+ * expressions as `caller.endUserId` / `caller.principal` / `caller.roles`.
712
+ * Omitted ⇒ null (server-caller mode). */
692
713
  caller?: HookCaller | null,
693
714
  ): ReadHookResult {
694
715
  if (!hooks) return { rows: [...rows], dropped: 0 };
package/src/index.ts CHANGED
@@ -534,6 +534,15 @@ const OidcProviderSchema = Type.Object({
534
534
  email: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
535
535
  name: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
536
536
  roles: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
537
+ // G-38b: an optional namespace prepended to every mapped IdP role (e.g.
538
+ // `idp-`), so an IdP group named like an app role (`finance`, `owner`)
539
+ // arrives as `idp-finance` and cannot pass a `readRoles: ['finance']`
540
+ // gate. Absent = the raw group names (the shipped behaviour). The prefixed
541
+ // role still obeys the 32-char role bound (longer ones are dropped).
542
+ // The alphabet is the cms readRoles / orgs role slug alphabet
543
+ // (`^[a-z0-9][a-z0-9_-]*$`, cms-v1 READ_ROLE_RE), so a prefixed role can
544
+ // itself be named in a gate — a `:`/`.`/uppercase prefix never could.
545
+ rolePrefix: Type.Optional(Type.String({ minLength: 1, maxLength: 16, pattern: '^[a-z0-9][a-z0-9_-]{0,15}$' })),
537
546
  })),
538
547
  // when non-empty, the asserted email's domain MUST be listed (fail-closed:
539
548
  // no email ⇒ refused) → 403 oidc_domain_not_allowed; a listed domain also
@@ -763,6 +772,26 @@ export const AuthConfigSchema = Type.Object({
763
772
  Type.String({ minLength: 8, maxLength: 253, pattern: REDIRECT_ORIGIN_PATTERN }),
764
773
  { maxItems: 32 },
765
774
  )),
775
+ // G-3 sign-up gating (2026-10-05). Both Optional with NO default, so an
776
+ // existing manifest folds byte-identically and the gate is opt-in. When
777
+ // EITHER is set, every self-service account-create path (password sign-up,
778
+ // magic-link / OTP pre-register, the guest → email claim, social sign-in
779
+ // with google/github/apple/facebook) is checked, fail-closed:
780
+ // • inviteOnly — a new account only for an address the tenant already
781
+ // put in its user registry server-side (POST /v1/users, an import);
782
+ // • allowedEmailDomains — otherwise, only for an address at one of these
783
+ // domains (exact, lowercase; a provider must assert the address
784
+ // VERIFIED).
785
+ // With either set, password sign-up is refused (it proves nothing about
786
+ // the address — a stranger could squat an invitee's address), guest
787
+ // sign-in is refused (a guest is nobody's invitee), and an existing
788
+ // account is never affected. The generic OIDC issuer keeps its own gate
789
+ // (providers.oidc.allowedDomains + IdP assignment). auth-v1 signupGate.ts.
790
+ inviteOnly: Type.Optional(Type.Boolean()),
791
+ allowedEmailDomains: Type.Optional(Type.Array(
792
+ Type.String({ minLength: 3, maxLength: 253, pattern: '^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$' }),
793
+ { maxItems: 32 },
794
+ )),
766
795
  })),
767
796
  });
768
797
  /** The security bag as persisted (present ⇒ leaf defaults applied). */
@@ -1489,6 +1518,12 @@ export const RagConfigSchema = Type.Object({
1489
1518
  ),
1490
1519
  // drop retrieved chunks below this score BEFORE budgeting (0 = keep all).
1491
1520
  minScore: Type.Number({ default: 0 }),
1521
+ // drop retrieved chunks whose cosine `similarity` to the query is
1522
+ // below this floor (-1..1). Unlike minScore (a floor on the RRF rank
1523
+ // value, ~0.016-0.033 at the top) this is the relevance threshold a RAG
1524
+ // app means. Absent = no floor; a hit with no measured similarity (no
1525
+ // query vector: keyword mode) is never dropped by it.
1526
+ minSimilarity: Type.Optional(Type.Number({ minimum: -1, maximum: 1 })),
1492
1527
  // forward rerank:true to vector-search's post-RRF BYO rerank pass (it owns
1493
1528
  // the provider + key); inert until that pass exists/is configured there.
1494
1529
  rerank: Type.Boolean({ default: false }),
@@ -1530,9 +1565,9 @@ export const RagConfigSchema = Type.Object({
1530
1565
  streaming: Type.Boolean({ default: true }),
1531
1566
  });
1532
1567
  // Leaves: enabled(1), defaultCollection(2),
1533
- // retrieval.{topK,mode,minScore,rerank}(+4=6), boosts(7 — Type.Record = ONE leaf),
1534
- // context.{maxTokens,strategy,tokenizer}(+3=10), defaultTemplate(11),
1535
- // citations(12), streaming(13). countLeaves → 13. Cap = 15.
1568
+ // retrieval.{topK,mode,minScore,minSimilarity,rerank}(+5=7), boosts(8 — Type.Record = ONE
1569
+ // leaf), context.{maxTokens,strategy,tokenizer}(+3=11), defaultTemplate(12),
1570
+ // citations(13), streaming(14). countLeaves → 14. Cap = 15.
1536
1571
 
1537
1572
  export type RagConfig = Static<typeof RagConfigSchema>;
1538
1573
 
package/src/readmodels.ts CHANGED
@@ -246,3 +246,42 @@ export function validateCdcConfig(
246
246
  }
247
247
  return errs;
248
248
  }
249
+
250
+ /** What a payload:'full' change-data rule must know about its target
251
+ * collection's read gates. `readRoleFields`: fields with a non-empty
252
+ * `readRoles`; `endUserAccess`: the collection's end-user access mode. */
253
+ export interface CdcCollectionGates {
254
+ readRoleFields?: readonly string[];
255
+ endUserAccess?: 'readwrite' | 'read' | 'none';
256
+ }
257
+
258
+ /**
259
+ * A `payload: 'full'` change-data rule inlines the stored row into a realtime
260
+ * frame that every subscriber of the channel receives — it does not go through
261
+ * the read projection. So it must never target a collection whose reads are
262
+ * gated: a field with `readRoles` (the frame would carry the gated value), or
263
+ * an `endUserAccess` of 'read' / 'none' (the frame would bypass the
264
+ * collection's end-user gate). Pure; one problem line per offending ENABLED
265
+ * rule (an unknown collection is skipped). The safe shape is `payload: 'ids'`
266
+ * and a re-read through the cms, where every gate applies.
267
+ */
268
+ export function cdcFullFramesOverGatedCollections(
269
+ cdc: unknown, collections: Record<string, CdcCollectionGates>,
270
+ ): string[] {
271
+ if (!cdc || typeof cdc !== 'object' || Array.isArray(cdc)) return [];
272
+ const problems: string[] = [];
273
+ for (const [name, raw] of Object.entries(cdc as Record<string, unknown>)) {
274
+ const r = raw as { collection?: unknown; payload?: unknown; enabled?: unknown } | null;
275
+ if (!r || r.payload !== 'full' || r.enabled === false || typeof r.collection !== 'string') continue;
276
+ const g = Object.prototype.hasOwnProperty.call(collections, r.collection) ? collections[r.collection] : undefined;
277
+ if (!g) continue;
278
+ const gated = (g.readRoleFields ?? []).filter((f) => typeof f === 'string');
279
+ const access = g.endUserAccess ?? 'readwrite';
280
+ if (gated.length > 0) {
281
+ problems.push(`cdc '${name}' publishes full '${r.collection}' rows, but field(s) ${gated.join(', ')} declare readRoles — every subscriber would receive them; use payload: 'ids' and re-read`);
282
+ } else if (access !== 'readwrite') {
283
+ problems.push(`cdc '${name}' publishes full '${r.collection}' rows, but the collection's endUserAccess is '${access}' — a full frame would bypass that gate for every subscriber; use payload: 'ids' and re-read`);
284
+ }
285
+ }
286
+ return problems;
287
+ }