@vxil/cli 0.24.2 → 0.26.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.
@@ -1,4 +1,4 @@
1
- import type { DeclaredOrgRole, DeclaredRlPolicy, DeclaredWebhookSubscription } from './_vxil-feature-configs.js';
1
+ import type { DeclaredOrgRole, DeclaredRlPolicy, DeclaredSearchCollection, DeclaredWebhookSubscription } from './_vxil-feature-configs.js';
2
2
  /** The target_url path markers of the platform's function-delivery lanes
3
3
  * (control-plane fnCmsHooks.ts FN_CMSHOOK_PATH / FN_AUTHHOOK_PATH /
4
4
  * FN_WEBHOOK_TRIGGER_PATH — mirrored here because this package is
@@ -6,7 +6,7 @@ import type { DeclaredOrgRole, DeclaredRlPolicy, DeclaredWebhookSubscription } f
6
6
  * manifest by the deploy reconciler: never declarable, never "undeclared". */
7
7
  export declare const FN_TRIGGER_TARGET_MARKERS: readonly ["/v1/internal/fn/cms-hook/", "/v1/internal/fn/auth-hook/", "/v1/internal/fn/trigger/"];
8
8
  export declare function isFnTriggerSubscriptionUrl(targetUrl: string): boolean;
9
- export type ApiStateDatum = 'policies' | 'subscriptions' | 'templates' | 'roles';
9
+ export type ApiStateDatum = 'policies' | 'subscriptions' | 'templates' | 'roles' | 'collections';
10
10
  /** feature → the declared-API-state datum its config block may carry. */
11
11
  export declare const API_STATE_DATUMS: ReadonlyArray<{
12
12
  feature: string;
@@ -147,6 +147,90 @@ export interface OrgRolePlan {
147
147
  undeclared: string[];
148
148
  }
149
149
  export declare function planOrgRoles(declared: readonly DeclaredOrgRole[], live: readonly LiveOrgRole[]): OrgRolePlan;
150
+ /** ONE row of GET /v1/search/collections. `model` / `api_key_ref` are listed by
151
+ * current servers; absent on an older server (then not compared). */
152
+ export interface LiveSearchCollection {
153
+ collection: string;
154
+ dimensions: number;
155
+ provider?: string | null;
156
+ model?: string | null;
157
+ api_key_ref?: string | null;
158
+ /** 'arabic' | 'none'; absent on an older server (then not compared). */
159
+ keyword_normalize?: string | null;
160
+ documents?: number;
161
+ /** WORKSPACE-ROWS Phase 2: listed (true) only on an orgScoped collection */
162
+ org_scoped?: boolean;
163
+ }
164
+ /** The POST /v1/search/collections body an update sends: always the live
165
+ * dimensions (immutable — an omitted width would fall back to the tenant
166
+ * default and 422). With the provider UNCHANGED every embed field the
167
+ * declaration omits keeps its live value, so a partial declaration never
168
+ * resets the rest to the tenant default (the route is an upsert of the whole
169
+ * embed config). On a PROVIDER CHANGE only the declared fields are sent — the
170
+ * old provider's model / key never carry over to the new one. */
171
+ export interface SearchCollectionBody {
172
+ collection: string;
173
+ dimensions?: number;
174
+ embed?: {
175
+ provider?: string;
176
+ model?: string;
177
+ apiKeyRef?: string;
178
+ };
179
+ /** sent only when declared (the route keeps the live fold when omitted) */
180
+ keywordNormalize?: 'arabic' | 'none';
181
+ /** create only — immutable after create (an update never sends it, so the
182
+ * server keeps the live value) */
183
+ org_scoped?: boolean;
184
+ }
185
+ export interface SearchCollectionPlan {
186
+ create: SearchCollectionBody[];
187
+ /** an embed field the declaration sets differs — POST upserts in place
188
+ * (same collection, documents kept). `reembed`: the provider or model
189
+ * changes on a collection that holds documents (or whose count the server
190
+ * did not list) — the indexed vectors came from the old embedder and new
191
+ * queries would use the new one, so the write is destructive-shaped
192
+ * (pending without --allow-destructive). An apiKeyRef-only change never is. */
193
+ update: Array<{
194
+ body: SearchCollectionBody;
195
+ changed: string[];
196
+ reembed: boolean;
197
+ }>;
198
+ unchanged: string[];
199
+ /** declared `dimensions` differ from the live ones — immutable after
200
+ * create (422 dimensions_immutable), so never written: a refused row */
201
+ immutable: Array<{
202
+ collection: string;
203
+ declared: number;
204
+ live: number;
205
+ }>;
206
+ /** an update the planner refuses to send (a provider change that does not
207
+ * declare the model / apiKeyRef the new provider needs) — a failed row */
208
+ refused: Array<{
209
+ collection: string;
210
+ error: string;
211
+ }>;
212
+ /** live collections the config does not declare — reported, never deleted
213
+ * (there is no collection delete route) */
214
+ undeclared: string[];
215
+ }
216
+ export declare function planSearchCollections(declared: readonly DeclaredSearchCollection[], live: readonly LiveSearchCollection[]): SearchCollectionPlan;
217
+ /** The refused-row error of a declared dimensions change. */
218
+ export declare function searchCollectionImmutableError(i: {
219
+ declared: number;
220
+ live: number;
221
+ }): string;
222
+ /** `vxil pull`: the `vector-search.collections` leaf from the live list —
223
+ * name, dimensions and the pinned embed config, so the pulled file plans
224
+ * with zero drift and a second environment gets the same embedder. Past the
225
+ * declared cap the leaf is NOT written (a pulled file must validate) and
226
+ * `note` says why (the orgs.roles posture). */
227
+ export declare function pulledSearchCollections(rows: readonly Record<string, unknown>[]): {
228
+ collections?: DeclaredSearchCollection[];
229
+ note?: string;
230
+ };
231
+ /** = SEARCH_COLLECTION_DECLARED_CAP (index.ts; not imported — this module is
232
+ * imported BY index.ts). A parity test pins the two. */
233
+ export declare const SEARCH_COLLECTION_PULL_CAP = 50;
150
234
  export interface ApiStateSummary {
151
235
  datum: ApiStateDatum;
152
236
  created: string[];
@@ -168,7 +252,18 @@ export interface ApiStateSummary {
168
252
  key: string;
169
253
  error: string;
170
254
  }>;
255
+ /** follow-up the operator must act on for a row that DID converge (e.g. a
256
+ * search collection whose keyword fold was turned on needs a re-ingest);
257
+ * absent = none (older servers never send it) */
258
+ notes?: Array<{
259
+ key: string;
260
+ note: string;
261
+ }>;
171
262
  }
263
+ /** The note a converge reports when it turned the keyword fold ON for a
264
+ * search collection that already holds documents (the server answered
265
+ * keyword_reindex_required: true). */
266
+ export declare const SEARCH_KEYWORD_REINDEX_NOTE = "keyword fold turned on \u2014 chunks indexed before it match only their exact spelling until re-ingested (re-ingest, or reset the sync source)";
172
267
  export declare function emptyApiStateSummary(datum: ApiStateDatum): ApiStateSummary;
173
268
  /** The `api_state` object a config-write response carries (`PUT /v1/config/
174
269
  * :feature` and its dashboard twin, dry-run included — control-plane
@@ -97,6 +97,13 @@ export interface HookCaller {
97
97
  * caller object still reads
98
98
  * as "no roles". */
99
99
  roles?: readonly string[] | null;
100
+ /** Workspace scope: the session's ACTIVE org (`caller.org.id` /
101
+ * `caller.org.role`), read-only; null outside active-org mode or with no
102
+ * active org. Optional so an older caller object reads as "no org". */
103
+ org?: {
104
+ id: string;
105
+ role: string | null;
106
+ } | null;
100
107
  }
101
108
  export interface HookContext {
102
109
  item: Record<string, unknown>;
@@ -33,16 +33,37 @@ interface CdcRuleLike {
33
33
  payload?: unknown;
34
34
  enabled?: boolean;
35
35
  }
36
+ /** The template token: one WHOLE colon-separated segment of a cdc `channel`. */
37
+ export declare const CDC_ORG_TOKEN = "{org}";
38
+ /** Every resolved workspace channel starts with this prefix; realtime-v1 binds
39
+ * an end-user connect token for such a channel to the verified `org_id` claim
40
+ * (its own copy: `workers/realtime-v1/src/token.ts` ORG_CHANNEL_PREFIX; the
41
+ * `cdc-gated-fields` gate pins both copies equal). */
42
+ export declare const ORG_CHANNEL_PREFIX = "org:";
43
+ /** An org id that may enter a channel name: no ':' (it would shift the segment
44
+ * realtime compares), the realtime channel alphabet, ≤ 64 chars (orgs ids are
45
+ * `org_…`, at most 64 chars). */
46
+ export declare const ORG_CHANNEL_ID_RE: RegExp;
47
+ /** Is `channel` an org template — exactly one segment equal to `{org}`, every
48
+ * other segment a non-empty channel-alphabet run, ≤ 128 chars in all? */
49
+ export declare function isOrgChannelTemplate(channel: string): boolean;
50
+ /** Resolve a template for one row's workspace: drop the `{org}` segment and
51
+ * prefix `org:<id>:` (`notes:{org}` → `org:<id>:notes`; a bare `{org}` →
52
+ * `org:<id>`). Null (publish nothing) for a non-template or an org id that is
53
+ * not a string matching ORG_CHANNEL_ID_RE — fail closed. */
54
+ export declare function resolveOrgChannel(template: string, orgId: unknown): string | null;
36
55
  /** Cross-field rule for CmsConfig.cdc (called from validateFeatureConfig). */
37
56
  export declare function validateCdcConfig(cdc: Record<string, CdcRuleLike> | undefined): string[];
38
57
  /** What a payload:'full' change-data rule must know about its target
39
58
  * collection's read gates. `readRoleFields`: fields with a non-empty
40
59
  * `readRoles`; `endUserAccess`: the collection's end-user access mode;
41
- * `ownerField`: the end-user owner-scope field (null/absent = none). */
60
+ * `ownerField`: the end-user owner-scope field (null/absent = none);
61
+ * `orgField`: the workspace-scope field (null/absent = none). */
42
62
  export interface CdcCollectionGates {
43
63
  readRoleFields?: readonly string[];
44
64
  endUserAccess?: 'readwrite' | 'read' | 'none';
45
65
  ownerField?: string | null;
66
+ orgField?: string | null;
46
67
  }
47
68
  /**
48
69
  * A `payload: 'full'` change-data rule inlines the stored row into a realtime
@@ -52,9 +73,21 @@ export interface CdcCollectionGates {
52
73
  * an `endUserAccess` of 'read' / 'none' (the frame would bypass the
53
74
  * collection's end-user gate), or an `ownerField` (end users read only their
54
75
  * own rows, but the frame would carry every user's row to every subscriber —
55
- * CDC-FULL-OWNER-SCOPED). Pure; one problem line per offending ENABLED
76
+ * CDC-FULL-OWNER-SCOPED), or an `orgField` (a workspace's rows to every
77
+ * subscriber — `{org}` frames stay ids-only). Pure; one problem line per offending ENABLED
56
78
  * rule (an unknown collection is skipped). The safe shape is `payload: 'ids'`
57
79
  * and a re-read through the cms, where every gate applies.
58
80
  */
59
81
  export declare function cdcFullFramesOverGatedCollections(cdc: unknown, collections: Record<string, CdcCollectionGates>): string[];
82
+ /**
83
+ * WORKSPACE-ROWS Phase 2: a FIXED-channel change-data rule on a workspace
84
+ * collection (live `orgField`) sends every workspace's change ids, status and
85
+ * timing to ONE channel any end user may subscribe to — only the `{org}`
86
+ * template (`<collection>:{org}` → `org:<org_id>:<collection>`) is valid there.
87
+ * The config PUT / draft-publish twin of the `workspaceManifestErrors` rule
88
+ * (which judges only DECLARED collections); the cms runtime floor
89
+ * (cdc.ts cdcChannelFor) drops such a rule for every door that cannot check.
90
+ * Pure; one problem line per offending ENABLED rule.
91
+ */
92
+ export declare function cdcFixedChannelsOverOrgCollections(cdc: unknown, collections: Record<string, CdcCollectionGates>): string[];
60
93
  export {};
@@ -323,6 +323,7 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
323
323
  }>>;
324
324
  orgClaims: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
325
325
  enabled: import("@sinclair/typebox").TBoolean;
326
+ activeOrg: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
326
327
  }>>;
327
328
  security: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
328
329
  lockout: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
@@ -332,8 +333,10 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
332
333
  }>>;
333
334
  breachedPasswords: import("@sinclair/typebox").TBoolean;
334
335
  captchaSecretRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
336
+ captchaOnReset: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
335
337
  allowedRedirectOrigins: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
336
338
  inviteOnly: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
339
+ verifyLinkEvicts: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
337
340
  allowedEmailDomains: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
338
341
  }>>;
339
342
  }>;
@@ -410,6 +413,10 @@ export declare const FilesConfigSchema: import("@sinclair/typebox").TObject<{
410
413
  q: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
411
414
  }>>>;
412
415
  }>>;
416
+ orgScope: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
417
+ readRoles: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
418
+ writeRoles: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
419
+ }>>;
413
420
  }>;
414
421
  export type FilesConfig = Static<typeof FilesConfigSchema>;
415
422
  export declare const DeclaredWebhookSubscriptionSchema: import("@sinclair/typebox").TObject<{
@@ -579,6 +586,30 @@ export declare const ActivityFeedConfigSchema: import("@sinclair/typebox").TObje
579
586
  }>;
580
587
  }>;
581
588
  export type ActivityFeedConfig = Static<typeof ActivityFeedConfigSchema>;
589
+ /** At most this many declared search collections (the converge's worst-case
590
+ * fan-out: one POST per declared entry per push). */
591
+ export declare const SEARCH_COLLECTION_DECLARED_CAP = 50;
592
+ /** One declared search collection — the POST /v1/search/collections
593
+ * body (vector-search-v1 core.ts CreateCollectionIn), keyed by `collection`.
594
+ * An omitted field is NOT compared against a live collection (it takes the
595
+ * vector-search default on create only); `dimensions` are immutable after
596
+ * create — a declared change is a refused row, never a recreate. */
597
+ /** The widths a search collection accepts — = vector-search-v1 backend.ts
598
+ * ALLOWED_DIMS (one indexed vector column each; a parity test pins them), so
599
+ * an unsupported width fails validation instead of only the push. */
600
+ export declare const SEARCH_COLLECTION_DIMS: readonly [256, 384, 512, 768, 1024, 1536, 3072];
601
+ export declare const DeclaredSearchCollectionSchema: import("@sinclair/typebox").TObject<{
602
+ collection: import("@sinclair/typebox").TString;
603
+ dimensions: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<256 | 384 | 512 | 768 | 1024 | 1536 | 3072>[]>>;
604
+ embed: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
605
+ provider: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"byov">, import("@sinclair/typebox").TLiteral<"mock">, import("@sinclair/typebox").TLiteral<"openai">, import("@sinclair/typebox").TLiteral<"cohere">]>>;
606
+ model: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
607
+ apiKeyRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
608
+ }>>;
609
+ keywordNormalize: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"arabic">, import("@sinclair/typebox").TLiteral<"none">]>>;
610
+ orgScoped: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
611
+ }>;
612
+ export type DeclaredSearchCollection = Static<typeof DeclaredSearchCollectionSchema>;
582
613
  export declare const VectorSearchConfigSchema: import("@sinclair/typebox").TObject<{
583
614
  enabled: import("@sinclair/typebox").TBoolean;
584
615
  backend: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"auto">, import("@sinclair/typebox").TLiteral<"lakebase">, import("@sinclair/typebox").TLiteral<"pgvector">]>;
@@ -616,6 +647,17 @@ export declare const VectorSearchConfigSchema: import("@sinclair/typebox").TObje
616
647
  statusFilter: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"published">, import("@sinclair/typebox").TLiteral<"all">]>>;
617
648
  cron: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
618
649
  }>>>;
650
+ collections: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
651
+ collection: import("@sinclair/typebox").TString;
652
+ dimensions: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<256 | 384 | 512 | 768 | 1024 | 1536 | 3072>[]>>;
653
+ embed: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
654
+ provider: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"byov">, import("@sinclair/typebox").TLiteral<"mock">, import("@sinclair/typebox").TLiteral<"openai">, import("@sinclair/typebox").TLiteral<"cohere">]>>;
655
+ model: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
656
+ apiKeyRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
657
+ }>>;
658
+ keywordNormalize: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"arabic">, import("@sinclair/typebox").TLiteral<"none">]>>;
659
+ orgScoped: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
660
+ }>>>;
619
661
  }>;
620
662
  export type VectorSearchConfig = Static<typeof VectorSearchConfigSchema>;
621
663
  export declare const AI_TEMPLATE_NAME_PATTERN = "^[a-zA-Z0-9_.\\-]+$";
@@ -766,6 +808,12 @@ export declare const FunctionsConfigSchema: import("@sinclair/typebox").TObject<
766
808
  metering: import("@sinclair/typebox").TObject<{
767
809
  enabled: import("@sinclair/typebox").TBoolean;
768
810
  }>;
811
+ logDrain: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
812
+ url: import("@sinclair/typebox").TString;
813
+ secretRef: import("@sinclair/typebox").TString;
814
+ minLevel: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"debug">, import("@sinclair/typebox").TLiteral<"info">, import("@sinclair/typebox").TLiteral<"log">, import("@sinclair/typebox").TLiteral<"warn">, import("@sinclair/typebox").TLiteral<"error">]>>;
815
+ dailyLines: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
816
+ }>>;
769
817
  functions: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
770
818
  bindings: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
771
819
  kind: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"http">, import("@sinclair/typebox").TLiteral<"queue">, import("@sinclair/typebox").TLiteral<"cron">, import("@sinclair/typebox").TLiteral<"webhook">, import("@sinclair/typebox").TLiteral<"cmsHook">, import("@sinclair/typebox").TLiteral<"authHook">]>;
@@ -920,6 +968,7 @@ export declare function fnServerScopesError(raw: unknown, fnScopes?: readonly st
920
968
  * templateVars parity). */
921
969
  export declare function rlTemplateVars(template: string): string[];
922
970
  export declare function validateDeclaredRlPolicies(policies: readonly DeclaredRlPolicy[] | undefined): string[];
971
+ export declare function validateDeclaredSearchCollections(collections: readonly DeclaredSearchCollection[] | undefined): string[];
923
972
  export declare function validateDeclaredOrgRoles(roles: readonly DeclaredOrgRole[] | undefined): string[];
924
973
  export declare function validateDeclaredWebhookSubscriptions(subscriptions: readonly DeclaredWebhookSubscription[] | undefined, maxSubscriptions: number | undefined): string[];
925
974
  /** The stored-template `schema` bounds — MIRRORED from @vxil/runtime
@@ -946,3 +995,15 @@ export interface PlannerHint {
946
995
  dependsOn: FeatureKey[];
947
996
  }
948
997
  export declare const FEATURE_HINTS: Record<FeatureKey, PlannerHint>;
998
+ /** = cms-v1 core.ts RETAIN_DAYS_MAX (a parity test pins the two). */
999
+ export declare const CMS_RETAIN_DAYS_MAX = 3650;
1000
+ /** A declared `retainDays`: absent = NOT MANAGED (the live value is left as
1001
+ * is — a collection whose retention was set by API is never silently
1002
+ * cleared by a push that does not mention it); `null` = keep items until
1003
+ * deleted; an integer 1..3650 = days. */
1004
+ export declare function isDeclaredRetainDays(v: unknown): v is number | null | undefined;
1005
+ /** Does moving retention from `remote` to `declared` make the sweep delete
1006
+ * MORE items (turning retention on, or shortening it)? On an EXISTING
1007
+ * collection that change applies only under allow-destructive; lengthening
1008
+ * or clearing keeps more and always applies. */
1009
+ export declare function retainDaysShortens(declared: number | null, remote: number | null): boolean;
package/dist/config.d.ts CHANGED
@@ -132,13 +132,90 @@ export interface CollectionDef {
132
132
  * a config leaf) and carried by BOTH push paths; widening (clearing, or
133
133
  * adding a role) needs `vxil push --allow-destructive`. */
134
134
  writeRoles?: string[];
135
+ /** Retention in days (guide ch. 4): items created more than this many days
136
+ * ago are soft-deleted by the nightly sweep (recoverable for 30 days). An
137
+ * integer 1..3650, or `null` to keep items until deleted. ABSENT = not
138
+ * managed by config — a value set by API or in the dashboard is left as is.
139
+ * Set at create; on an existing collection, turning retention on or
140
+ * shortening it needs `vxil push --allow-destructive` (lengthening or
141
+ * `null` always applies). Carried by BOTH push paths. */
142
+ retainDays?: number | null;
135
143
  /** Per-record action buttons (guide ch. 4): `[{ key, label, fn }]`, stored on
136
144
  * the collection (model data, not a config leaf). The dashboard renders one
137
145
  * button per action on each record row; `POST /v1/cms/items/:coll/:id/actions/
138
146
  * :key` invokes `fn` once. Carried by BOTH push paths (create + reconcile on
139
147
  * existing collections — config is the source of truth; `[]`/absent clears). */
140
148
  actions?: CmsActionDef[];
149
+ /** WORKSPACE SCOPING (guide ch. 4 / 9): names an existing `string` field with
150
+ * an `indexSlot` in `s1`–`s4` that holds the row's workspace (an orgs
151
+ * `org_id`). A VERIFIED end user then reads and writes only the rows of the
152
+ * org their session is ACTIVE in; server keys are unaffected. Needs
153
+ * `features.auth.orgClaims: { enabled: true, activeOrg: true }`. May coexist
154
+ * with `ownerField` (both predicates apply). Not together with a `cdc` rule or
155
+ * a vector-search `sync[]` source in this release. Carried by BOTH push paths
156
+ * (wire `org_field`); clearing it WIDENS access and needs
157
+ * `vxil push --allow-destructive`. */
158
+ orgField?: string;
159
+ /** The ACTIVE-ORG role slugs one of which an end user must hold to READ this
160
+ * `orgField` collection (absent = any member of the workspace). Up to 16 slugs
161
+ * (`^[a-z0-9][a-z0-9_-]{0,31}$`); refused without `orgField`. Carried by BOTH
162
+ * push paths (wire `org_read_roles`); clearing it, or adding a role, WIDENS
163
+ * access and needs `vxil push --allow-destructive`. */
164
+ orgReadRoles?: string[];
141
165
  }
166
+ /** The cms role-slug alphabet (workers/cms-v1/src/readRoles.ts READ_ROLE_RE):
167
+ * gate slugs are at most 32 characters. */
168
+ export declare const CMS_ROLE_SLUG_RE: RegExp;
169
+ /** At most this many `orgReadRoles` slugs per collection (cms/0195 CHECK). */
170
+ export declare const CMS_ORG_READ_ROLES_MAX = 16;
171
+ /** A collection's declared orgField in either spelling (config `orgField`, wire
172
+ * `org_field` — the /v1/apply bundle accepts both); a non-empty string or null. */
173
+ export declare function declaredOrgField(def: unknown): string | null;
174
+ /** Collection-local WORKSPACE rules — the same refusals cms-v1 makes on the
175
+ * collection upsert, so a config fails at `defineConfig` / `collection()` /
176
+ * `/v1/apply` instead of mid-push. Accepts both spellings (`orgField` /
177
+ * `org_field`, `orgReadRoles` / `org_read_roles`, `indexSlot` / `index_slot`).
178
+ * Returns every violation (empty = valid). */
179
+ export declare function workspaceCollectionErrors(name: string, def: unknown): string[];
180
+ /** Throwing form of `workspaceCollectionErrors` (first violation). */
181
+ export declare function assertWorkspaceCollection(name: string, def: unknown): void;
182
+ /** The manifest shape `workspaceManifestErrors` reads: a `VxilConfig` or a
183
+ * `/v1/apply` bundle (features keyed by feature id, cms collections in either
184
+ * spelling). */
185
+ export interface WorkspaceManifestInput {
186
+ features?: Record<string, unknown> | VxilConfig['features'];
187
+ cms?: {
188
+ collections?: Record<string, unknown>;
189
+ };
190
+ }
191
+ /** CROSS-FEATURE workspace rules — the ones only the WHOLE manifest can see
192
+ * (the cms reconciler sees one collection). Run by `defineConfig` (so every
193
+ * CLI push evaluates it) and by control-plane `/v1/apply` (the server-side
194
+ * authority). Returns every violation (empty = valid):
195
+ * - orgField needs `features.auth.orgClaims.enabled` AND `.activeOrg`;
196
+ * - a `features.cms.cdc` rule on an orgField collection must use the
197
+ * `{org}` channel template (Phase 2), stay ids-only, and not target a
198
+ * collection with `orgReadRoles` (the channel admits every member); a
199
+ * template on a declared collection WITHOUT orgField is refused;
200
+ * - a vector-search `sync[]` source reading an orgField collection must
201
+ * target a collection declared in `vector-search.collections[]` with
202
+ * `orgScoped: true` (Phase 2 — each document takes the row's orgField as
203
+ * its org), and an unscoped source never feeds an orgScoped collection;
204
+ * - an orgScoped search collection needs `auth.orgClaims.activeOrg` too;
205
+ * - a read model materializing an orgField source must target an orgField
206
+ * collection whose orgField has the SAME name, and group (or partition) by
207
+ * the source orgField — so each rollup row lands in its own workspace;
208
+ * - an `orgs.roles` key over 32 characters cannot be named in a gate
209
+ * (orgReadRoles / writeRoles / field readRoles) of an orgField collection. */
210
+ export declare function workspaceManifestErrors(config: WorkspaceManifestInput,
211
+ /** `checkAuth: false` skips the activeOrg rule — `/v1/apply` passes it when
212
+ * the bundle neither declares an orgField collection nor features.auth, so
213
+ * pre-existing live state alone never blocks an unrelated apply. */
214
+ opts?: {
215
+ checkAuth?: boolean;
216
+ }): string[];
217
+ /** Throwing form of `workspaceManifestErrors` — run by `defineConfig`. */
218
+ export declare function assertWorkspaceManifest(config: WorkspaceManifestInput): void;
142
219
  /** The auth lifecycle events an `authHook` binding may subscribe to
143
220
  * (2026-09-10) — a CLOSED union; ONE event per binding, one audit
144
221
  * event → one function invocation, no branching inside vxil:
package/dist/config.js CHANGED
@@ -16,6 +16,166 @@ function assertIndexedFields(name, def) {
16
16
  throw new Error(`vxil.config: cms.${name} has ${n} indexed fields \u2014 at most ${CMS_INDEXED_FIELD_BUDGET} per collection`);
17
17
  }
18
18
  }
19
+ var CMS_ROLE_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
20
+ var CMS_ORG_READ_ROLES_MAX = 16;
21
+ var ORG_FIELD_SLOTS = /* @__PURE__ */ new Set(["s1", "s2", "s3", "s4"]);
22
+ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
23
+ function declaredOrgField(def) {
24
+ if (!isObj(def)) return null;
25
+ const v = def.orgField ?? def.org_field;
26
+ return typeof v === "string" && v.length > 0 ? v : null;
27
+ }
28
+ function workspaceCollectionErrors(name, def) {
29
+ if (!isObj(def)) return [];
30
+ const errs = [];
31
+ const at = `cms.collections.${name}`;
32
+ const camel = def.orgField;
33
+ const snake = def.org_field;
34
+ if (camel !== void 0 && snake !== void 0 && camel !== snake) {
35
+ errs.push(`${at} sets orgField and org_field to different values \u2014 send one`);
36
+ }
37
+ const rawOrg = camel ?? snake;
38
+ let orgField = null;
39
+ if (rawOrg !== void 0 && rawOrg !== null) {
40
+ if (typeof rawOrg !== "string" || rawOrg.length === 0) {
41
+ errs.push(`${at}.orgField must be a field name (got ${JSON.stringify(rawOrg)})`);
42
+ } else {
43
+ orgField = rawOrg;
44
+ const f = isObj(def.fields) ? def.fields[rawOrg] : void 0;
45
+ if (!isObj(f)) {
46
+ errs.push(`${at}.orgField '${rawOrg}' names no declared field`);
47
+ } else {
48
+ if (f.type !== "string") {
49
+ errs.push(`${at}.orgField '${rawOrg}' must be a string field (is ${JSON.stringify(f.type)})`);
50
+ }
51
+ const slot = f.indexSlot ?? f.index_slot;
52
+ if (typeof slot !== "string" || !ORG_FIELD_SLOTS.has(slot)) {
53
+ errs.push(`${at}.orgField '${rawOrg}' needs an indexSlot in s1\u2013s4 (every end-user read filters on it)`);
54
+ }
55
+ }
56
+ }
57
+ }
58
+ const owner = def.ownerField ?? def.owner_field;
59
+ if (orgField !== null && owner === orgField) {
60
+ errs.push(`${at}.orgField and ownerField must name different fields (both are '${orgField}')`);
61
+ }
62
+ const rrCamel = def.orgReadRoles;
63
+ const rrSnake = def.org_read_roles;
64
+ if (rrCamel !== void 0 && rrSnake !== void 0 && JSON.stringify(rrCamel) !== JSON.stringify(rrSnake)) {
65
+ errs.push(`${at} sets orgReadRoles and org_read_roles to different values \u2014 send one`);
66
+ }
67
+ const rr = rrCamel ?? rrSnake;
68
+ if (rr !== void 0 && rr !== null) {
69
+ if (!Array.isArray(rr) || rr.some((r) => typeof r !== "string" || !CMS_ROLE_SLUG_RE.test(r))) {
70
+ errs.push(`${at}.orgReadRoles must be an array of role slugs matching ${CMS_ROLE_SLUG_RE} (got ${JSON.stringify(rr)})`);
71
+ } else if (rr.length > CMS_ORG_READ_ROLES_MAX) {
72
+ errs.push(`${at}.orgReadRoles has ${rr.length} entries \u2014 at most ${CMS_ORG_READ_ROLES_MAX}`);
73
+ } else if (rr.length > 0 && orgField === null) {
74
+ errs.push(`${at}.orgReadRoles needs orgField (it gates reads of a workspace-scoped collection)`);
75
+ }
76
+ }
77
+ return errs;
78
+ }
79
+ function assertWorkspaceCollection(name, def) {
80
+ const errs = workspaceCollectionErrors(name, def);
81
+ if (errs.length) throw new Error(`vxil.config: ${errs[0]}`);
82
+ }
83
+ var isOrgTemplateChannel = (ch) => typeof ch === "string" && ch.split(":").includes("{org}");
84
+ var orgReadRolesOfDef = (def) => isObj(def) ? def.orgReadRoles ?? def.org_read_roles : void 0;
85
+ var ownerFieldOfDef = (def) => {
86
+ const v = isObj(def) ? def.ownerField ?? def.owner_field : void 0;
87
+ return typeof v === "string" && v.length > 0;
88
+ };
89
+ var asList = (v) => Array.isArray(v) ? v : v === void 0 || v === null ? [] : [v];
90
+ var stringsOf = (v) => asList(v).filter((s) => typeof s === "string");
91
+ function workspaceManifestErrors(config, opts = {}) {
92
+ const errs = [];
93
+ const rawCollections = config.cms?.collections;
94
+ const collections = isObj(rawCollections) ? rawCollections : {};
95
+ const orgCols = /* @__PURE__ */ new Map();
96
+ for (const [name, def] of Object.entries(collections)) {
97
+ const of = declaredOrgField(def);
98
+ if (of) orgCols.set(name, of);
99
+ }
100
+ const features = isObj(config.features) ? config.features : {};
101
+ const cms = isObj(features.cms) ? features.cms : {};
102
+ const cdcRules = Object.entries(isObj(cms.cdc) ? cms.cdc : {});
103
+ for (const [rule, r] of cdcRules) {
104
+ if (isObj(r) && typeof r.collection === "string" && isOrgTemplateChannel(r.channel) && Object.prototype.hasOwnProperty.call(collections, r.collection) && !orgCols.has(r.collection)) {
105
+ errs.push(`features.cms.cdc.${rule}: a '{org}' channel needs an orgField collection \u2014 '${r.collection}' declares no orgField, so no row has a workspace to publish to`);
106
+ }
107
+ }
108
+ const vs = isObj(features["vector-search"]) ? features["vector-search"] : {};
109
+ const orgScopedTargets = new Set(asList(vs.collections).filter((c) => isObj(c) && (c.orgScoped === true || c.org_scoped === true) && typeof c.collection === "string").map((c) => c.collection));
110
+ if (orgCols.size === 0 && orgScopedTargets.size === 0) return errs;
111
+ const names = [...orgCols.keys()].join(", ");
112
+ const auth = isObj(features.auth) ? features.auth : {};
113
+ const oc = isObj(auth.orgClaims) ? auth.orgClaims : {};
114
+ if (opts.checkAuth !== false && (oc.enabled !== true || oc.activeOrg !== true)) {
115
+ if (orgCols.size) {
116
+ errs.push(`cms.collections ${names} set orgField, which needs features.auth.orgClaims { enabled: true, activeOrg: true } \u2014 without an active org the session carries no stable workspace`);
117
+ }
118
+ if (orgScopedTargets.size) {
119
+ errs.push(`vector-search.collections ${[...orgScopedTargets].join(", ")} set orgScoped, which needs features.auth.orgClaims { enabled: true, activeOrg: true } \u2014 without an active org no end user can read them`);
120
+ }
121
+ }
122
+ for (const [rule, r] of cdcRules) {
123
+ if (!isObj(r) || typeof r.collection !== "string" || !orgCols.has(r.collection)) continue;
124
+ if (!isOrgTemplateChannel(r.channel)) {
125
+ errs.push(`features.cms.cdc.${rule}: collection '${r.collection}' is workspace-scoped (orgField) \u2014 a fixed cdc channel is not access control; use a workspace channel template, e.g. channel: '${r.collection}:{org}' (each row publishes to org:<its org id>:${r.collection}, which only members of that workspace can subscribe to)`);
126
+ } else if (r.payload === "full") {
127
+ errs.push(`features.cms.cdc.${rule}: collection '${r.collection}' is workspace-scoped (orgField) \u2014 frames stay ids-only (payload: 'ids'); subscribers re-read through cms, where the workspace predicate applies`);
128
+ } else if (stringsOf(orgReadRolesOfDef(collections[r.collection])).length > 0) {
129
+ errs.push(`features.cms.cdc.${rule}: collection '${r.collection}' sets orgReadRoles \u2014 an org:<id> channel admits every member of the workspace, so its frames (item ids, status, timing) would reach roles that cannot read the collection; drop the rule or the orgReadRoles`);
130
+ }
131
+ }
132
+ asList(vs.sync).forEach((s, i) => {
133
+ if (!isObj(s) || typeof s.cmsCollection !== "string") return;
134
+ const target = typeof s.collection === "string" ? s.collection : "";
135
+ if (orgCols.has(s.cmsCollection) && !orgScopedTargets.has(target)) {
136
+ errs.push(`features.vector-search.sync[${i}]: cmsCollection '${s.cmsCollection}' is workspace-scoped (orgField) \u2014 the target '${target}' must be declared in vector-search.collections[] with orgScoped: true, or every workspace's rows become searchable by any end user`);
137
+ } else if (orgCols.has(s.cmsCollection) && (stringsOf(orgReadRolesOfDef(collections[s.cmsCollection])).length > 0 || ownerFieldOfDef(collections[s.cmsCollection]))) {
138
+ const which = stringsOf(orgReadRolesOfDef(collections[s.cmsCollection])).length > 0 ? "orgReadRoles" : "ownerField";
139
+ errs.push(`features.vector-search.sync[${i}]: cmsCollection '${s.cmsCollection}' sets ${which} \u2014 synced documents are shared by every member of the workspace, so a member could search rows cms refuses them; drop the sync or the ${which}`);
140
+ } else if (!orgCols.has(s.cmsCollection) && orgScopedTargets.has(target)) {
141
+ errs.push(`features.vector-search.sync[${i}]: target '${target}' is orgScoped but cmsCollection '${s.cmsCollection}' is not workspace-scoped (orgField) \u2014 its documents would carry no workspace`);
142
+ }
143
+ });
144
+ for (const [rmName, rm] of Object.entries(isObj(cms.readModels) ? cms.readModels : {})) {
145
+ if (!isObj(rm) || typeof rm.collection !== "string") continue;
146
+ const srcOrg = orgCols.get(rm.collection);
147
+ const to = isObj(rm.materialize) ? rm.materialize.to : void 0;
148
+ if (!srcOrg || typeof to !== "string") continue;
149
+ const tOrg = orgCols.get(to);
150
+ const spec = isObj(rm.spec) ? rm.spec : {};
151
+ const groups = [...stringsOf(spec.groupBy), ...stringsOf(spec.partitionBy)];
152
+ if (tOrg !== srcOrg || !groups.includes(srcOrg)) {
153
+ errs.push(`features.cms.readModels.${rmName}: materializes workspace-scoped '${rm.collection}' into '${to}' \u2014 the target must be an orgField collection with orgField '${srcOrg}' and the read model must group by '${srcOrg}', or one workspace's rollup rows land where every end user can read them`);
154
+ }
155
+ }
156
+ const orgs = isObj(features.orgs) ? features.orgs : {};
157
+ const longRoles = new Set(asList(orgs.roles).map((r) => isObj(r) ? r.role_key : void 0).filter((k) => typeof k === "string" && k.length > 32));
158
+ for (const [name] of orgCols) {
159
+ const def = collections[name];
160
+ const gate = /* @__PURE__ */ new Set([
161
+ ...stringsOf(def.orgReadRoles ?? def.org_read_roles),
162
+ ...stringsOf(def.writeRoles ?? def.write_roles)
163
+ ]);
164
+ for (const f of Object.values(isObj(def.fields) ? def.fields : {})) {
165
+ if (isObj(f)) for (const r of stringsOf(f.readRoles ?? f.read_roles)) gate.add(r);
166
+ }
167
+ for (const r of gate) {
168
+ if (r.length > 32 || longRoles.has(r)) {
169
+ errs.push(`cms.collections.${name}: gate role '${r}' is longer than 32 characters \u2014 an orgs role named in a cms gate must be 32 characters or fewer (longer keys stay legal in orgs)`);
170
+ }
171
+ }
172
+ }
173
+ return errs;
174
+ }
175
+ function assertWorkspaceManifest(config) {
176
+ const errs = workspaceManifestErrors(config);
177
+ if (errs.length) throw new Error(`vxil.config: ${errs.join("; ")}`);
178
+ }
19
179
  var RELEASED_API_VERSIONS = ["v1"];
20
180
  function defineConfig(c) {
21
181
  if (c.apiVersion !== void 0 && !RELEASED_API_VERSIONS.includes(c.apiVersion)) {
@@ -23,7 +183,11 @@ function defineConfig(c) {
23
183
  `vxil.config: apiVersion '${String(c.apiVersion)}' is not a released API major (released: ${RELEASED_API_VERSIONS.map((v) => `'${v}'`).join(", ")}). A new major ships as a new path with its own deprecation window \u2014 see https://vxil.com/docs/guide/12-going-to-production.`
24
184
  );
25
185
  }
26
- for (const [name, def] of Object.entries(c.cms?.collections ?? {})) assertIndexedFields(name, def);
186
+ for (const [name, def] of Object.entries(c.cms?.collections ?? {})) {
187
+ assertIndexedFields(name, def);
188
+ assertWorkspaceCollection(name, def);
189
+ }
190
+ assertWorkspaceManifest(c);
27
191
  return c;
28
192
  }
29
193
  function field(type, opts = {}) {
@@ -31,13 +195,21 @@ function field(type, opts = {}) {
31
195
  }
32
196
  function collection(def) {
33
197
  assertIndexedFields(def.singular ?? "collection", def);
198
+ assertWorkspaceCollection(def.singular ?? "collection", def);
34
199
  return def;
35
200
  }
36
201
  export {
37
202
  CMS_INDEXED_FIELD_BUDGET,
203
+ CMS_ORG_READ_ROLES_MAX,
204
+ CMS_ROLE_SLUG_RE,
38
205
  RELEASED_API_VERSIONS,
39
206
  assertIndexedFields,
207
+ assertWorkspaceCollection,
208
+ assertWorkspaceManifest,
40
209
  collection,
210
+ declaredOrgField,
41
211
  defineConfig,
42
- field
212
+ field,
213
+ workspaceCollectionErrors,
214
+ workspaceManifestErrors
43
215
  };