@vxil/cli 0.24.1 → 0.25.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,81 @@ 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
+ documents?: number;
159
+ }
160
+ /** The POST /v1/search/collections body an update sends: always the live
161
+ * dimensions (immutable — an omitted width would fall back to the tenant
162
+ * default and 422). With the provider UNCHANGED every embed field the
163
+ * declaration omits keeps its live value, so a partial declaration never
164
+ * resets the rest to the tenant default (the route is an upsert of the whole
165
+ * embed config). On a PROVIDER CHANGE only the declared fields are sent — the
166
+ * old provider's model / key never carry over to the new one. */
167
+ export interface SearchCollectionBody {
168
+ collection: string;
169
+ dimensions?: number;
170
+ embed?: {
171
+ provider?: string;
172
+ model?: string;
173
+ apiKeyRef?: string;
174
+ };
175
+ }
176
+ export interface SearchCollectionPlan {
177
+ create: SearchCollectionBody[];
178
+ /** an embed field the declaration sets differs — POST upserts in place
179
+ * (same collection, documents kept). `reembed`: the provider or model
180
+ * changes on a collection that holds documents (or whose count the server
181
+ * did not list) — the indexed vectors came from the old embedder and new
182
+ * queries would use the new one, so the write is destructive-shaped
183
+ * (pending without --allow-destructive). An apiKeyRef-only change never is. */
184
+ update: Array<{
185
+ body: SearchCollectionBody;
186
+ changed: string[];
187
+ reembed: boolean;
188
+ }>;
189
+ unchanged: string[];
190
+ /** declared `dimensions` differ from the live ones — immutable after
191
+ * create (422 dimensions_immutable), so never written: a refused row */
192
+ immutable: Array<{
193
+ collection: string;
194
+ declared: number;
195
+ live: number;
196
+ }>;
197
+ /** an update the planner refuses to send (a provider change that does not
198
+ * declare the model / apiKeyRef the new provider needs) — a failed row */
199
+ refused: Array<{
200
+ collection: string;
201
+ error: string;
202
+ }>;
203
+ /** live collections the config does not declare — reported, never deleted
204
+ * (there is no collection delete route) */
205
+ undeclared: string[];
206
+ }
207
+ export declare function planSearchCollections(declared: readonly DeclaredSearchCollection[], live: readonly LiveSearchCollection[]): SearchCollectionPlan;
208
+ /** The refused-row error of a declared dimensions change. */
209
+ export declare function searchCollectionImmutableError(i: {
210
+ declared: number;
211
+ live: number;
212
+ }): string;
213
+ /** `vxil pull`: the `vector-search.collections` leaf from the live list —
214
+ * name, dimensions and the pinned embed config, so the pulled file plans
215
+ * with zero drift and a second environment gets the same embedder. Past the
216
+ * declared cap the leaf is NOT written (a pulled file must validate) and
217
+ * `note` says why (the orgs.roles posture). */
218
+ export declare function pulledSearchCollections(rows: readonly Record<string, unknown>[]): {
219
+ collections?: DeclaredSearchCollection[];
220
+ note?: string;
221
+ };
222
+ /** = SEARCH_COLLECTION_DECLARED_CAP (index.ts; not imported — this module is
223
+ * imported BY index.ts). A parity test pins the two. */
224
+ export declare const SEARCH_COLLECTION_PULL_CAP = 50;
150
225
  export interface ApiStateSummary {
151
226
  datum: ApiStateDatum;
152
227
  created: string[];
@@ -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>;
@@ -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<{
@@ -334,6 +335,7 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
334
335
  captchaSecretRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
335
336
  allowedRedirectOrigins: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
336
337
  inviteOnly: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
338
+ verifyLinkEvicts: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
337
339
  allowedEmailDomains: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
338
340
  }>>;
339
341
  }>;
@@ -579,6 +581,28 @@ export declare const ActivityFeedConfigSchema: import("@sinclair/typebox").TObje
579
581
  }>;
580
582
  }>;
581
583
  export type ActivityFeedConfig = Static<typeof ActivityFeedConfigSchema>;
584
+ /** At most this many declared search collections (the converge's worst-case
585
+ * fan-out: one POST per declared entry per push). */
586
+ export declare const SEARCH_COLLECTION_DECLARED_CAP = 50;
587
+ /** One declared search collection — the POST /v1/search/collections
588
+ * body (vector-search-v1 core.ts CreateCollectionIn), keyed by `collection`.
589
+ * An omitted field is NOT compared against a live collection (it takes the
590
+ * vector-search default on create only); `dimensions` are immutable after
591
+ * create — a declared change is a refused row, never a recreate. */
592
+ /** The widths a search collection accepts — = vector-search-v1 backend.ts
593
+ * ALLOWED_DIMS (one indexed vector column each; a parity test pins them), so
594
+ * an unsupported width fails validation instead of only the push. */
595
+ export declare const SEARCH_COLLECTION_DIMS: readonly [256, 384, 512, 768, 1024, 1536, 3072];
596
+ export declare const DeclaredSearchCollectionSchema: import("@sinclair/typebox").TObject<{
597
+ collection: import("@sinclair/typebox").TString;
598
+ dimensions: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<256 | 384 | 512 | 768 | 1024 | 1536 | 3072>[]>>;
599
+ embed: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
600
+ 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">]>>;
601
+ model: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
602
+ apiKeyRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
603
+ }>>;
604
+ }>;
605
+ export type DeclaredSearchCollection = Static<typeof DeclaredSearchCollectionSchema>;
582
606
  export declare const VectorSearchConfigSchema: import("@sinclair/typebox").TObject<{
583
607
  enabled: import("@sinclair/typebox").TBoolean;
584
608
  backend: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"auto">, import("@sinclair/typebox").TLiteral<"lakebase">, import("@sinclair/typebox").TLiteral<"pgvector">]>;
@@ -616,6 +640,15 @@ export declare const VectorSearchConfigSchema: import("@sinclair/typebox").TObje
616
640
  statusFilter: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"published">, import("@sinclair/typebox").TLiteral<"all">]>>;
617
641
  cron: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
618
642
  }>>>;
643
+ collections: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
644
+ collection: import("@sinclair/typebox").TString;
645
+ dimensions: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<256 | 384 | 512 | 768 | 1024 | 1536 | 3072>[]>>;
646
+ embed: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
647
+ 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">]>>;
648
+ model: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
649
+ apiKeyRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
650
+ }>>;
651
+ }>>>;
619
652
  }>;
620
653
  export type VectorSearchConfig = Static<typeof VectorSearchConfigSchema>;
621
654
  export declare const AI_TEMPLATE_NAME_PATTERN = "^[a-zA-Z0-9_.\\-]+$";
@@ -766,6 +799,12 @@ export declare const FunctionsConfigSchema: import("@sinclair/typebox").TObject<
766
799
  metering: import("@sinclair/typebox").TObject<{
767
800
  enabled: import("@sinclair/typebox").TBoolean;
768
801
  }>;
802
+ logDrain: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
803
+ url: import("@sinclair/typebox").TString;
804
+ secretRef: import("@sinclair/typebox").TString;
805
+ 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">]>>;
806
+ dailyLines: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
807
+ }>>;
769
808
  functions: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
770
809
  bindings: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
771
810
  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 +959,7 @@ export declare function fnServerScopesError(raw: unknown, fnScopes?: readonly st
920
959
  * templateVars parity). */
921
960
  export declare function rlTemplateVars(template: string): string[];
922
961
  export declare function validateDeclaredRlPolicies(policies: readonly DeclaredRlPolicy[] | undefined): string[];
962
+ export declare function validateDeclaredSearchCollections(collections: readonly DeclaredSearchCollection[] | undefined): string[];
923
963
  export declare function validateDeclaredOrgRoles(roles: readonly DeclaredOrgRole[] | undefined): string[];
924
964
  export declare function validateDeclaredWebhookSubscriptions(subscriptions: readonly DeclaredWebhookSubscription[] | undefined, maxSubscriptions: number | undefined): string[];
925
965
  /** The stored-template `schema` bounds — MIRRORED from @vxil/runtime
@@ -946,3 +986,15 @@ export interface PlannerHint {
946
986
  dependsOn: FeatureKey[];
947
987
  }
948
988
  export declare const FEATURE_HINTS: Record<FeatureKey, PlannerHint>;
989
+ /** = cms-v1 core.ts RETAIN_DAYS_MAX (a parity test pins the two). */
990
+ export declare const CMS_RETAIN_DAYS_MAX = 3650;
991
+ /** A declared `retainDays`: absent = NOT MANAGED (the live value is left as
992
+ * is — a collection whose retention was set by API is never silently
993
+ * cleared by a push that does not mention it); `null` = keep items until
994
+ * deleted; an integer 1..3650 = days. */
995
+ export declare function isDeclaredRetainDays(v: unknown): v is number | null | undefined;
996
+ /** Does moving retention from `remote` to `declared` make the sweep delete
997
+ * MORE items (turning retention on, or shortening it)? On an EXISTING
998
+ * collection that change applies only under allow-destructive; lengthening
999
+ * or clearing keeps more and always applies. */
1000
+ export declare function retainDaysShortens(declared: number | null, remote: number | null): boolean;
package/dist/config.d.ts CHANGED
@@ -132,13 +132,83 @@ 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
+ * - no `features.cms.cdc` rule on an orgField collection (Phase 1);
197
+ * - no vector-search `sync[]` source reading an orgField collection (Phase 1);
198
+ * - a read model materializing an orgField source must target an orgField
199
+ * collection whose orgField has the SAME name, and group (or partition) by
200
+ * the source orgField — so each rollup row lands in its own workspace;
201
+ * - an `orgs.roles` key over 32 characters cannot be named in a gate
202
+ * (orgReadRoles / writeRoles / field readRoles) of an orgField collection. */
203
+ export declare function workspaceManifestErrors(config: WorkspaceManifestInput,
204
+ /** `checkAuth: false` skips the activeOrg rule — `/v1/apply` passes it when
205
+ * the bundle neither declares an orgField collection nor features.auth, so
206
+ * pre-existing live state alone never blocks an unrelated apply. */
207
+ opts?: {
208
+ checkAuth?: boolean;
209
+ }): string[];
210
+ /** Throwing form of `workspaceManifestErrors` — run by `defineConfig`. */
211
+ export declare function assertWorkspaceManifest(config: WorkspaceManifestInput): void;
142
212
  /** The auth lifecycle events an `authHook` binding may subscribe to
143
213
  * (2026-09-10) — a CLOSED union; ONE event per binding, one audit
144
214
  * event → one function invocation, no branching inside vxil:
package/dist/config.js CHANGED
@@ -16,6 +16,136 @@ 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 asList = (v) => Array.isArray(v) ? v : v === void 0 || v === null ? [] : [v];
84
+ var stringsOf = (v) => asList(v).filter((s) => typeof s === "string");
85
+ function workspaceManifestErrors(config, opts = {}) {
86
+ const errs = [];
87
+ const rawCollections = config.cms?.collections;
88
+ const collections = isObj(rawCollections) ? rawCollections : {};
89
+ const orgCols = /* @__PURE__ */ new Map();
90
+ for (const [name, def] of Object.entries(collections)) {
91
+ const of = declaredOrgField(def);
92
+ if (of) orgCols.set(name, of);
93
+ }
94
+ if (orgCols.size === 0) return errs;
95
+ const features = isObj(config.features) ? config.features : {};
96
+ const names = [...orgCols.keys()].join(", ");
97
+ const auth = isObj(features.auth) ? features.auth : {};
98
+ const oc = isObj(auth.orgClaims) ? auth.orgClaims : {};
99
+ if (opts.checkAuth !== false && (oc.enabled !== true || oc.activeOrg !== true)) {
100
+ 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`);
101
+ }
102
+ const cms = isObj(features.cms) ? features.cms : {};
103
+ for (const [rule, r] of Object.entries(isObj(cms.cdc) ? cms.cdc : {})) {
104
+ if (isObj(r) && typeof r.collection === "string" && orgCols.has(r.collection)) {
105
+ errs.push(`features.cms.cdc.${rule}: collection '${r.collection}' is workspace-scoped (orgField) \u2014 a cdc channel is not access control, so it is refused until org channels ship (Phase 2); fan out from a function with per-workspace channel names meanwhile`);
106
+ }
107
+ }
108
+ const vs = isObj(features["vector-search"]) ? features["vector-search"] : {};
109
+ asList(vs.sync).forEach((s, i) => {
110
+ if (isObj(s) && typeof s.cmsCollection === "string" && orgCols.has(s.cmsCollection)) {
111
+ errs.push(`features.vector-search.sync[${i}]: cmsCollection '${s.cmsCollection}' is workspace-scoped (orgField) \u2014 workspace-scoped sync arrives with orgScoped (Phase 2); search through a function meanwhile`);
112
+ }
113
+ });
114
+ for (const [rmName, rm] of Object.entries(isObj(cms.readModels) ? cms.readModels : {})) {
115
+ if (!isObj(rm) || typeof rm.collection !== "string") continue;
116
+ const srcOrg = orgCols.get(rm.collection);
117
+ const to = isObj(rm.materialize) ? rm.materialize.to : void 0;
118
+ if (!srcOrg || typeof to !== "string") continue;
119
+ const tOrg = orgCols.get(to);
120
+ const spec = isObj(rm.spec) ? rm.spec : {};
121
+ const groups = [...stringsOf(spec.groupBy), ...stringsOf(spec.partitionBy)];
122
+ if (tOrg !== srcOrg || !groups.includes(srcOrg)) {
123
+ 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`);
124
+ }
125
+ }
126
+ const orgs = isObj(features.orgs) ? features.orgs : {};
127
+ const longRoles = new Set(asList(orgs.roles).map((r) => isObj(r) ? r.role_key : void 0).filter((k) => typeof k === "string" && k.length > 32));
128
+ for (const [name] of orgCols) {
129
+ const def = collections[name];
130
+ const gate = /* @__PURE__ */ new Set([
131
+ ...stringsOf(def.orgReadRoles ?? def.org_read_roles),
132
+ ...stringsOf(def.writeRoles ?? def.write_roles)
133
+ ]);
134
+ for (const f of Object.values(isObj(def.fields) ? def.fields : {})) {
135
+ if (isObj(f)) for (const r of stringsOf(f.readRoles ?? f.read_roles)) gate.add(r);
136
+ }
137
+ for (const r of gate) {
138
+ if (r.length > 32 || longRoles.has(r)) {
139
+ 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)`);
140
+ }
141
+ }
142
+ }
143
+ return errs;
144
+ }
145
+ function assertWorkspaceManifest(config) {
146
+ const errs = workspaceManifestErrors(config);
147
+ if (errs.length) throw new Error(`vxil.config: ${errs.join("; ")}`);
148
+ }
19
149
  var RELEASED_API_VERSIONS = ["v1"];
20
150
  function defineConfig(c) {
21
151
  if (c.apiVersion !== void 0 && !RELEASED_API_VERSIONS.includes(c.apiVersion)) {
@@ -23,7 +153,11 @@ function defineConfig(c) {
23
153
  `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
154
  );
25
155
  }
26
- for (const [name, def] of Object.entries(c.cms?.collections ?? {})) assertIndexedFields(name, def);
156
+ for (const [name, def] of Object.entries(c.cms?.collections ?? {})) {
157
+ assertIndexedFields(name, def);
158
+ assertWorkspaceCollection(name, def);
159
+ }
160
+ assertWorkspaceManifest(c);
27
161
  return c;
28
162
  }
29
163
  function field(type, opts = {}) {
@@ -31,13 +165,21 @@ function field(type, opts = {}) {
31
165
  }
32
166
  function collection(def) {
33
167
  assertIndexedFields(def.singular ?? "collection", def);
168
+ assertWorkspaceCollection(def.singular ?? "collection", def);
34
169
  return def;
35
170
  }
36
171
  export {
37
172
  CMS_INDEXED_FIELD_BUDGET,
173
+ CMS_ORG_READ_ROLES_MAX,
174
+ CMS_ROLE_SLUG_RE,
38
175
  RELEASED_API_VERSIONS,
39
176
  assertIndexedFields,
177
+ assertWorkspaceCollection,
178
+ assertWorkspaceManifest,
40
179
  collection,
180
+ declaredOrgField,
41
181
  defineConfig,
42
- field
182
+ field,
183
+ workspaceCollectionErrors,
184
+ workspaceManifestErrors
43
185
  };