@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)
|
|
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
|
-
* -
|
|
197
|
-
*
|
|
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
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
if (
|
|
105
|
-
errs.push(`features.cms.cdc.${rule}: collection '${r.collection}' is workspace-scoped (orgField) \u2014 a cdc channel is not access control
|
|
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)
|
|
111
|
-
|
|
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 : {})) {
|