@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 +25 -13
- package/dist/hooks.js +28 -12
- package/dist/index.d.ts +5 -0
- package/dist/index.js +32 -0
- package/dist/readmodels.d.ts +18 -0
- package/dist/readmodels.js +32 -0
- package/package.json +1 -1
- package/src/hooks.ts +36 -15
- package/src/index.ts +38 -3
- package/src/readmodels.ts +39 -0
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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 }),
|
package/dist/readmodels.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/readmodels.js
CHANGED
|
@@ -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.
|
|
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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
|
349
|
-
*
|
|
350
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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}(+
|
|
1534
|
-
// context.{maxTokens,strategy,tokenizer}(+3=
|
|
1535
|
-
// citations(
|
|
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
|
+
}
|