@vxil/cli 0.25.0 → 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.
@@ -155,7 +155,11 @@ export interface LiveSearchCollection {
155
155
  provider?: string | null;
156
156
  model?: string | null;
157
157
  api_key_ref?: string | null;
158
+ /** 'arabic' | 'none'; absent on an older server (then not compared). */
159
+ keyword_normalize?: string | null;
158
160
  documents?: number;
161
+ /** WORKSPACE-ROWS Phase 2: listed (true) only on an orgScoped collection */
162
+ org_scoped?: boolean;
159
163
  }
160
164
  /** The POST /v1/search/collections body an update sends: always the live
161
165
  * dimensions (immutable — an omitted width would fall back to the tenant
@@ -172,6 +176,11 @@ export interface SearchCollectionBody {
172
176
  model?: string;
173
177
  apiKeyRef?: string;
174
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;
175
184
  }
176
185
  export interface SearchCollectionPlan {
177
186
  create: SearchCollectionBody[];
@@ -243,7 +252,18 @@ export interface ApiStateSummary {
243
252
  key: string;
244
253
  error: string;
245
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
+ }>;
246
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)";
247
267
  export declare function emptyApiStateSummary(datum: ApiStateDatum): ApiStateSummary;
248
268
  /** The `api_state` object a config-write response carries (`PUT /v1/config/
249
269
  * :feature` and its dashboard twin, dry-run included — control-plane
@@ -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 {};
@@ -333,6 +333,7 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
333
333
  }>>;
334
334
  breachedPasswords: import("@sinclair/typebox").TBoolean;
335
335
  captchaSecretRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
336
+ captchaOnReset: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
336
337
  allowedRedirectOrigins: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
337
338
  inviteOnly: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
338
339
  verifyLinkEvicts: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
@@ -412,6 +413,10 @@ export declare const FilesConfigSchema: import("@sinclair/typebox").TObject<{
412
413
  q: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
413
414
  }>>>;
414
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
+ }>>;
415
420
  }>;
416
421
  export type FilesConfig = Static<typeof FilesConfigSchema>;
417
422
  export declare const DeclaredWebhookSubscriptionSchema: import("@sinclair/typebox").TObject<{
@@ -601,6 +606,8 @@ export declare const DeclaredSearchCollectionSchema: import("@sinclair/typebox")
601
606
  model: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
602
607
  apiKeyRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
603
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>;
604
611
  }>;
605
612
  export type DeclaredSearchCollection = Static<typeof DeclaredSearchCollectionSchema>;
606
613
  export declare const VectorSearchConfigSchema: import("@sinclair/typebox").TObject<{
@@ -648,6 +655,8 @@ export declare const VectorSearchConfigSchema: import("@sinclair/typebox").TObje
648
655
  model: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
649
656
  apiKeyRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
650
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>;
651
660
  }>>>;
652
661
  }>;
653
662
  export type VectorSearchConfig = Static<typeof VectorSearchConfigSchema>;
package/dist/config.d.ts CHANGED
@@ -193,8 +193,15 @@ export interface WorkspaceManifestInput {
193
193
  * CLI push evaluates it) and by control-plane `/v1/apply` (the server-side
194
194
  * authority). Returns every violation (empty = valid):
195
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);
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;
198
205
  * - a read model materializing an orgField source must target an orgField
199
206
  * collection whose orgField has the SAME name, and group (or partition) by
200
207
  * the source orgField — so each rollup row lands in its own workspace;
package/dist/config.js CHANGED
@@ -80,6 +80,12 @@ function assertWorkspaceCollection(name, def) {
80
80
  const errs = workspaceCollectionErrors(name, def);
81
81
  if (errs.length) throw new Error(`vxil.config: ${errs[0]}`);
82
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
+ };
83
89
  var asList = (v) => Array.isArray(v) ? v : v === void 0 || v === null ? [] : [v];
84
90
  var stringsOf = (v) => asList(v).filter((s) => typeof s === "string");
85
91
  function workspaceManifestErrors(config, opts = {}) {
@@ -91,24 +97,48 @@ function workspaceManifestErrors(config, opts = {}) {
91
97
  const of = declaredOrgField(def);
92
98
  if (of) orgCols.set(name, of);
93
99
  }
94
- if (orgCols.size === 0) return errs;
95
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;
96
111
  const names = [...orgCols.keys()].join(", ");
97
112
  const auth = isObj(features.auth) ? features.auth : {};
98
113
  const oc = isObj(auth.orgClaims) ? auth.orgClaims : {};
99
114
  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`);
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
+ }
101
121
  }
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`);
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`);
106
130
  }
107
131
  }
108
- const vs = isObj(features["vector-search"]) ? features["vector-search"] : {};
109
132
  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`);
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`);
112
142
  }
113
143
  });
114
144
  for (const [rmName, rm] of Object.entries(isObj(cms.readModels) ? cms.readModels : {})) {