@vxil/cli 0.24.2 → 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.
- package/dist/_vxil-feature-configs-apiState.d.ts +77 -2
- package/dist/_vxil-feature-configs-hooks.d.ts +7 -0
- package/dist/_vxil-feature-configs.d.ts +52 -0
- package/dist/config.d.ts +70 -0
- package/dist/config.js +144 -2
- package/dist/vxil-main.js +699 -44
- package/package.json +3 -3
package/dist/vxil-main.js
CHANGED
|
@@ -3182,7 +3182,8 @@ var API_STATE_DATUMS = [
|
|
|
3182
3182
|
{ feature: "rate-limits", datum: "policies" },
|
|
3183
3183
|
{ feature: "webhooks", datum: "subscriptions" },
|
|
3184
3184
|
{ feature: "ai", datum: "templates" },
|
|
3185
|
-
{ feature: "orgs", datum: "roles" }
|
|
3185
|
+
{ feature: "orgs", datum: "roles" },
|
|
3186
|
+
{ feature: "vector-search", datum: "collections" }
|
|
3186
3187
|
];
|
|
3187
3188
|
function apiStateDatumOf(feature) {
|
|
3188
3189
|
return API_STATE_DATUMS.find((d) => d.feature === feature)?.datum ?? null;
|
|
@@ -3311,6 +3312,81 @@ function planOrgRoles(declared, live) {
|
|
|
3311
3312
|
for (const l of live) if (!declaredKeys.has(l.role_key)) plan.undeclared.push(l.role_key);
|
|
3312
3313
|
return plan;
|
|
3313
3314
|
}
|
|
3315
|
+
var KEYED_PROVIDERS = /* @__PURE__ */ new Set(["openai", "cohere"]);
|
|
3316
|
+
function planSearchCollections(declared, live) {
|
|
3317
|
+
const plan = { create: [], update: [], unchanged: [], immutable: [], refused: [], undeclared: [] };
|
|
3318
|
+
const byName = new Map(live.map((l) => [l.collection, l]));
|
|
3319
|
+
const declaredNames = new Set(declared.map((d) => d.collection));
|
|
3320
|
+
for (const d of declared) {
|
|
3321
|
+
const l = byName.get(d.collection);
|
|
3322
|
+
if (!l) {
|
|
3323
|
+
plan.create.push({
|
|
3324
|
+
collection: d.collection,
|
|
3325
|
+
...d.dimensions !== void 0 ? { dimensions: d.dimensions } : {},
|
|
3326
|
+
...d.embed && Object.keys(d.embed).length ? { embed: { ...d.embed } } : {}
|
|
3327
|
+
});
|
|
3328
|
+
continue;
|
|
3329
|
+
}
|
|
3330
|
+
if (d.dimensions !== void 0 && d.dimensions !== l.dimensions) {
|
|
3331
|
+
plan.immutable.push({ collection: d.collection, declared: d.dimensions, live: l.dimensions });
|
|
3332
|
+
continue;
|
|
3333
|
+
}
|
|
3334
|
+
const e = d.embed ?? {};
|
|
3335
|
+
const changed = [];
|
|
3336
|
+
const providerChanged = e.provider !== void 0 && e.provider !== (l.provider ?? null);
|
|
3337
|
+
if (providerChanged) changed.push("provider");
|
|
3338
|
+
if (e.model !== void 0 && l.model !== void 0 && e.model !== (l.model ?? null)) changed.push("model");
|
|
3339
|
+
if (e.apiKeyRef !== void 0 && l.api_key_ref !== void 0 && e.apiKeyRef !== (l.api_key_ref ?? null)) changed.push("apiKeyRef");
|
|
3340
|
+
if (!changed.length) {
|
|
3341
|
+
plan.unchanged.push(d.collection);
|
|
3342
|
+
continue;
|
|
3343
|
+
}
|
|
3344
|
+
let embed;
|
|
3345
|
+
if (providerChanged) {
|
|
3346
|
+
const missing = KEYED_PROVIDERS.has(e.provider) ? ["model", "apiKeyRef"].filter((k) => e[k] === void 0) : [];
|
|
3347
|
+
if (missing.length) {
|
|
3348
|
+
plan.refused.push({
|
|
3349
|
+
collection: d.collection,
|
|
3350
|
+
error: `provider change to ${e.provider} must also declare embed.${missing.join(" and embed.")} \u2014 the live ${l.provider ?? "default"} settings are never carried over to a new provider (and the new model must output the collection's ${l.dimensions} dimensions)`
|
|
3351
|
+
});
|
|
3352
|
+
continue;
|
|
3353
|
+
}
|
|
3354
|
+
embed = { ...e };
|
|
3355
|
+
} else {
|
|
3356
|
+
const provider = e.provider ?? l.provider ?? void 0;
|
|
3357
|
+
const model = e.model ?? l.model ?? void 0;
|
|
3358
|
+
const apiKeyRef = e.apiKeyRef ?? l.api_key_ref ?? void 0;
|
|
3359
|
+
embed = { ...provider ? { provider } : {}, ...model ? { model } : {}, ...apiKeyRef ? { apiKeyRef } : {} };
|
|
3360
|
+
}
|
|
3361
|
+
const reembed = (changed.includes("provider") || changed.includes("model")) && (l.documents ?? 1) > 0;
|
|
3362
|
+
plan.update.push({ body: { collection: d.collection, dimensions: l.dimensions, embed }, changed, reembed });
|
|
3363
|
+
}
|
|
3364
|
+
for (const l of live) if (!declaredNames.has(l.collection)) plan.undeclared.push(l.collection);
|
|
3365
|
+
return plan;
|
|
3366
|
+
}
|
|
3367
|
+
function searchCollectionImmutableError(i) {
|
|
3368
|
+
return `422 dimensions_immutable: declared dimensions ${i.declared} but the live collection has ${i.live} \u2014 dimensions are immutable; declare a new collection name and re-ingest`;
|
|
3369
|
+
}
|
|
3370
|
+
function pulledSearchCollections(rows) {
|
|
3371
|
+
if (rows.length > SEARCH_COLLECTION_PULL_CAP) {
|
|
3372
|
+
return { note: ` vector-search.collections: the tenant has ${rows.length} search collections, past the ${SEARCH_COLLECTION_PULL_CAP}-entry cap of a declared collections[] \u2014 not written; they stay live rows` };
|
|
3373
|
+
}
|
|
3374
|
+
return {
|
|
3375
|
+
collections: rows.map((r) => {
|
|
3376
|
+
const embed = {};
|
|
3377
|
+
if (typeof r.provider === "string") embed.provider = r.provider;
|
|
3378
|
+
if (typeof r.model === "string") embed.model = r.model;
|
|
3379
|
+
if (typeof r.api_key_ref === "string") embed.apiKeyRef = r.api_key_ref;
|
|
3380
|
+
return {
|
|
3381
|
+
collection: String(r.collection),
|
|
3382
|
+
// the server only ever stores an allowed width
|
|
3383
|
+
...typeof r.dimensions === "number" ? { dimensions: r.dimensions } : {},
|
|
3384
|
+
...Object.keys(embed).length ? { embed } : {}
|
|
3385
|
+
};
|
|
3386
|
+
})
|
|
3387
|
+
};
|
|
3388
|
+
}
|
|
3389
|
+
var SEARCH_COLLECTION_PULL_CAP = 50;
|
|
3314
3390
|
function emptyApiStateSummary(datum) {
|
|
3315
3391
|
return {
|
|
3316
3392
|
datum,
|
|
@@ -3355,7 +3431,9 @@ function formatApiStateDetail(s) {
|
|
|
3355
3431
|
for (const k of s.updated) lines.push(` ~ ${k}`);
|
|
3356
3432
|
for (const k of s.recreated) lines.push(` \u21BB ${k} (recreated \u2014 new id)`);
|
|
3357
3433
|
for (const k of s.deleted) lines.push(` - ${k} (deleted: undeclared, --allow-destructive)`);
|
|
3358
|
-
for (const k of s.undeclared)
|
|
3434
|
+
for (const k of s.undeclared) {
|
|
3435
|
+
lines.push(s.datum === "collections" ? ` \xB7 ${k} \u2014 live but not declared (left in place; a push never deletes a search collection)` : ` \xB7 ${k} \u2014 live but not declared (left in place; --allow-destructive deletes it)`);
|
|
3436
|
+
}
|
|
3359
3437
|
for (const k of s.pending_destructive) lines.push(` ! ${k} \u2014 needs delete+recreate (new id); re-run with --allow-destructive`);
|
|
3360
3438
|
for (const k of s.ambiguous) lines.push(` ! ${k} \u2014 more than one live row carries this name; delete the extras first`);
|
|
3361
3439
|
for (const f of s.failed) lines.push(` \u2717 ${f.key} \u2014 ${f.error}`);
|
|
@@ -3369,6 +3447,9 @@ var PUBLIC_ASSET_TYPES = {
|
|
|
3369
3447
|
webp: "image/webp",
|
|
3370
3448
|
avif: "image/avif",
|
|
3371
3449
|
gif: "image/gif",
|
|
3450
|
+
// Favicons. A raster container (BMP/PNG frames) — no
|
|
3451
|
+
// script, rendered only as an image; never a variant source.
|
|
3452
|
+
ico: "image/x-icon",
|
|
3372
3453
|
mp4: "video/mp4",
|
|
3373
3454
|
webm: "video/webm",
|
|
3374
3455
|
mp3: "audio/mpeg",
|
|
@@ -3382,6 +3463,7 @@ var TYPE_TO_EXT = {
|
|
|
3382
3463
|
...Object.fromEntries(Object.entries(PUBLIC_ASSET_TYPES).map(([ext, ct]) => [ct, ext])),
|
|
3383
3464
|
"image/jpg": "jpg",
|
|
3384
3465
|
"image/pjpeg": "jpg",
|
|
3466
|
+
"image/vnd.microsoft.icon": "ico",
|
|
3385
3467
|
"audio/mp3": "mp3",
|
|
3386
3468
|
"audio/x-m4a": "m4a",
|
|
3387
3469
|
"audio/m4a": "m4a",
|
|
@@ -3917,7 +3999,17 @@ var AuthConfigSchema = Type.Object({
|
|
|
3917
3999
|
// OPTIONAL bag = 1 leaf. Lives in AUTH config (not orgs) because auth-v1's
|
|
3918
4000
|
// CONFIG_KV is the auth namespace and cannot see orgs config.
|
|
3919
4001
|
orgClaims: Type.Optional(Type.Object({
|
|
3920
|
-
enabled: Type.Boolean({ default: false })
|
|
4002
|
+
enabled: Type.Boolean({ default: false }),
|
|
4003
|
+
// ACTIVE-ORG mode. Every mint/refresh stamps the session claim
|
|
4004
|
+
// `oam: 1`, the session row stores the org it acts in (active_org_id: the
|
|
4005
|
+
// most-recent membership at first mint, or the org picked by
|
|
4006
|
+
// POST /v1/auth/sessions/refresh { org_id }), a refresh never falls back to
|
|
4007
|
+
// another org, the org role is no longer folded into `roles`, membership
|
|
4008
|
+
// changes mark live sessions `claims_stale`, and orgs membership writes are
|
|
4009
|
+
// server-only for end users. Needs `enabled`. Optional WITHOUT a default
|
|
4010
|
+
// (absent = off) so existing manifests fold byte-identically; inside the
|
|
4011
|
+
// existing bag, so no new config leaf.
|
|
4012
|
+
activeOrg: Type.Optional(Type.Boolean())
|
|
3921
4013
|
})),
|
|
3922
4014
|
// Note: the earlier `redirects` block was DROPPED entirely —
|
|
3923
4015
|
// it had zero consumers (grep-verified: no worker reads config.redirects) and
|
|
@@ -3935,8 +4027,9 @@ var AuthConfigSchema = Type.Object({
|
|
|
3935
4027
|
// chars leave the worker, never the password) at sign-up / reset-confirm;
|
|
3936
4028
|
// fail-OPEN on network error → 422 password_breached.
|
|
3937
4029
|
// • captchaSecretRef — a secret-store ref (feature 'auth') holding the
|
|
3938
|
-
// Turnstile secret; when set, sign-up / OTP request / magic-link request
|
|
3939
|
-
//
|
|
4030
|
+
// Turnstile secret; when set, sign-up / OTP request / magic-link request /
|
|
4031
|
+
// email-verification request / password-reset request require
|
|
4032
|
+
// `captcha_token` (403 captcha_failed otherwise).
|
|
3940
4033
|
// • allowedRedirectOrigins — when non-empty, EVERY caller-supplied
|
|
3941
4034
|
// redirect/return URL (magic link, password reset, email verification,
|
|
3942
4035
|
// OAuth start) must match one origin exactly → 422 redirect_not_allowed.
|
|
@@ -3971,6 +4064,14 @@ var AuthConfigSchema = Type.Object({
|
|
|
3971
4064
|
// account is never affected. The generic OIDC issuer keeps its own gate
|
|
3972
4065
|
// (providers.oidc.allowedDomains + IdP assignment). auth-v1 signupGate.ts.
|
|
3973
4066
|
inviteOnly: Type.Optional(Type.Boolean()),
|
|
4067
|
+
// Squatted-address defence (guide ch. 9): a verify-link confirm with NO
|
|
4068
|
+
// matching session proves the mailbox only. With verifyLinkEvicts:true it
|
|
4069
|
+
// runs the eviction guard (clears a never-verified row's password, revokes
|
|
4070
|
+
// its sessions, drops its provider links — the squatter defence); absent /
|
|
4071
|
+
// false keeps the password, so a genuine sign-up who opens the link in a
|
|
4072
|
+
// mail-app browser (or an SDK that sends no session_token) is not locked
|
|
4073
|
+
// out. Optional, NO default: every existing manifest folds byte-identically.
|
|
4074
|
+
verifyLinkEvicts: Type.Optional(Type.Boolean()),
|
|
3974
4075
|
allowedEmailDomains: Type.Optional(Type.Array(
|
|
3975
4076
|
Type.String({ minLength: 3, maxLength: 253, pattern: "^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$" }),
|
|
3976
4077
|
{ maxItems: 32 }
|
|
@@ -4306,7 +4407,10 @@ var DeclaredOrgRoleSchema = Type.Object({
|
|
|
4306
4407
|
});
|
|
4307
4408
|
var OrgsConfigSchema = Type.Object({
|
|
4308
4409
|
enabled: Type.Boolean({ default: true }),
|
|
4309
|
-
|
|
4410
|
+
// Org cap: max 100,000 (was 10,000); the default stays 100 so
|
|
4411
|
+
// existing manifests fold byte-identically. The unpaged GET /v1/orgs page is
|
|
4412
|
+
// clamped at 10,000 (orgs-v1 parseOrgsPage), so no response grows.
|
|
4413
|
+
maxOrgsPerTenant: Type.Integer({ default: 100, minimum: 1, maximum: 1e5 }),
|
|
4310
4414
|
maxMembersPerOrg: Type.Integer({ default: 1e3, minimum: 1, maximum: 1e5 }),
|
|
4311
4415
|
invitationTtlHours: Type.Integer({ default: 168, minimum: 1, maximum: 720 }),
|
|
4312
4416
|
// Declared API state (G-31): the tenant's CUSTOM role catalog. Converged by
|
|
@@ -4387,6 +4491,22 @@ var ActivityFeedConfigSchema = Type.Object({
|
|
|
4387
4491
|
// per-tenant request meter are the real bounds). Re-declare it together with
|
|
4388
4492
|
// the binding that enforces it.
|
|
4389
4493
|
});
|
|
4494
|
+
var SEARCH_COLLECTION_DECLARED_CAP = 50;
|
|
4495
|
+
var SEARCH_COLLECTION_DIMS = [256, 384, 512, 768, 1024, 1536, 3072];
|
|
4496
|
+
var DeclaredSearchCollectionSchema = Type.Object({
|
|
4497
|
+
collection: Type.String({ pattern: "^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$" }),
|
|
4498
|
+
dimensions: Type.Optional(Type.Union(SEARCH_COLLECTION_DIMS.map((d) => Type.Literal(d)))),
|
|
4499
|
+
embed: Type.Optional(Type.Object({
|
|
4500
|
+
provider: Type.Optional(Type.Union([
|
|
4501
|
+
Type.Literal("byov"),
|
|
4502
|
+
Type.Literal("mock"),
|
|
4503
|
+
Type.Literal("openai"),
|
|
4504
|
+
Type.Literal("cohere")
|
|
4505
|
+
])),
|
|
4506
|
+
model: Type.Optional(Type.String({ maxLength: 128 })),
|
|
4507
|
+
apiKeyRef: Type.Optional(Type.String({ maxLength: 128 }))
|
|
4508
|
+
}))
|
|
4509
|
+
});
|
|
4390
4510
|
var VectorSearchConfigSchema = Type.Object({
|
|
4391
4511
|
enabled: Type.Boolean({ default: true }),
|
|
4392
4512
|
// 'auto' resolves to the default managed vector backend for the tier.
|
|
@@ -4474,7 +4594,14 @@ var VectorSearchConfigSchema = Type.Object({
|
|
|
4474
4594
|
}),
|
|
4475
4595
|
{ maxItems: 8 }
|
|
4476
4596
|
)
|
|
4477
|
-
)
|
|
4597
|
+
),
|
|
4598
|
+
// Declared API state: the tenant's search
|
|
4599
|
+
// collections, converged by `collection` name by `vxil push` / POST
|
|
4600
|
+
// /v1/apply (control-plane handlers/apiState.ts) through POST
|
|
4601
|
+
// /v1/search/collections. Undeclared live collections are left and
|
|
4602
|
+
// reported — there is no collection delete route, so never deleted.
|
|
4603
|
+
// Absent = not managed by config. ARRAY = ONE leaf.
|
|
4604
|
+
collections: Type.Optional(Type.Array(DeclaredSearchCollectionSchema, { maxItems: SEARCH_COLLECTION_DECLARED_CAP }))
|
|
4478
4605
|
});
|
|
4479
4606
|
var AI_TEMPLATE_NAME_PATTERN = "^[a-zA-Z0-9_.\\-]+$";
|
|
4480
4607
|
var AI_MAX_DECLARED_TEMPLATES = 50;
|
|
@@ -4854,6 +4981,26 @@ var FunctionsConfigSchema = Type.Object({
|
|
|
4854
4981
|
{ enabled: Type.Boolean({ default: false }) },
|
|
4855
4982
|
{ default: {} }
|
|
4856
4983
|
),
|
|
4984
|
+
// FN-LOG-DRAIN (2026-10-12): forward this project's function log lines, in
|
|
4985
|
+
// signed batches, to the tenant's own https endpoint (functions-tail →
|
|
4986
|
+
// LogDrainDO). ONE optional leaf. The ONLY member of this deploy-managed
|
|
4987
|
+
// manifest a config PUT may change (handlers/functionsLogDrain.ts) — `vxil
|
|
4988
|
+
// push` carries it from `features.functions.logDrain`. `dailyLines` is
|
|
4989
|
+
// SERVER-SET from the plan at write time (LOG_DRAIN_LINES_PER_DAY_BY_TIER);
|
|
4990
|
+
// an authored value is overwritten. `secretRef` names a functions secret
|
|
4991
|
+
// (`vxil secrets set functions/<name>`), the HMAC key of the signature.
|
|
4992
|
+
logDrain: Type.Optional(Type.Object({
|
|
4993
|
+
url: Type.String({ minLength: 9, maxLength: 2048 }),
|
|
4994
|
+
secretRef: Type.String({ pattern: "^[a-z0-9_]{1,64}$" }),
|
|
4995
|
+
minLevel: Type.Optional(Type.Union([
|
|
4996
|
+
Type.Literal("debug"),
|
|
4997
|
+
Type.Literal("info"),
|
|
4998
|
+
Type.Literal("log"),
|
|
4999
|
+
Type.Literal("warn"),
|
|
5000
|
+
Type.Literal("error")
|
|
5001
|
+
])),
|
|
5002
|
+
dailyLines: Type.Optional(Type.Integer({ minimum: 0 }))
|
|
5003
|
+
})),
|
|
4857
5004
|
// ONE Type.Record leaf — the per-function object is DATA, not flags.
|
|
4858
5005
|
functions: Type.Optional(
|
|
4859
5006
|
Type.Record(
|
|
@@ -5103,6 +5250,138 @@ var FEATURE_KEYS = [
|
|
|
5103
5250
|
"functions",
|
|
5104
5251
|
"copilot"
|
|
5105
5252
|
];
|
|
5253
|
+
var CMS_RETAIN_DAYS_MAX = 3650;
|
|
5254
|
+
function isDeclaredRetainDays(v) {
|
|
5255
|
+
return v === void 0 || v === null || typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= CMS_RETAIN_DAYS_MAX;
|
|
5256
|
+
}
|
|
5257
|
+
function retainDaysShortens(declared, remote) {
|
|
5258
|
+
if (declared === null) return false;
|
|
5259
|
+
return remote === null || declared < remote;
|
|
5260
|
+
}
|
|
5261
|
+
|
|
5262
|
+
// ../config/src/index.ts
|
|
5263
|
+
var CMS_ROLE_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
5264
|
+
var CMS_ORG_READ_ROLES_MAX = 16;
|
|
5265
|
+
var ORG_FIELD_SLOTS = /* @__PURE__ */ new Set(["s1", "s2", "s3", "s4"]);
|
|
5266
|
+
var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
5267
|
+
function declaredOrgField(def) {
|
|
5268
|
+
if (!isObj(def)) return null;
|
|
5269
|
+
const v = def.orgField ?? def.org_field;
|
|
5270
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
5271
|
+
}
|
|
5272
|
+
function workspaceCollectionErrors(name, def) {
|
|
5273
|
+
if (!isObj(def)) return [];
|
|
5274
|
+
const errs = [];
|
|
5275
|
+
const at = `cms.collections.${name}`;
|
|
5276
|
+
const camel2 = def.orgField;
|
|
5277
|
+
const snake = def.org_field;
|
|
5278
|
+
if (camel2 !== void 0 && snake !== void 0 && camel2 !== snake) {
|
|
5279
|
+
errs.push(`${at} sets orgField and org_field to different values \u2014 send one`);
|
|
5280
|
+
}
|
|
5281
|
+
const rawOrg = camel2 ?? snake;
|
|
5282
|
+
let orgField = null;
|
|
5283
|
+
if (rawOrg !== void 0 && rawOrg !== null) {
|
|
5284
|
+
if (typeof rawOrg !== "string" || rawOrg.length === 0) {
|
|
5285
|
+
errs.push(`${at}.orgField must be a field name (got ${JSON.stringify(rawOrg)})`);
|
|
5286
|
+
} else {
|
|
5287
|
+
orgField = rawOrg;
|
|
5288
|
+
const f = isObj(def.fields) ? def.fields[rawOrg] : void 0;
|
|
5289
|
+
if (!isObj(f)) {
|
|
5290
|
+
errs.push(`${at}.orgField '${rawOrg}' names no declared field`);
|
|
5291
|
+
} else {
|
|
5292
|
+
if (f.type !== "string") {
|
|
5293
|
+
errs.push(`${at}.orgField '${rawOrg}' must be a string field (is ${JSON.stringify(f.type)})`);
|
|
5294
|
+
}
|
|
5295
|
+
const slot = f.indexSlot ?? f.index_slot;
|
|
5296
|
+
if (typeof slot !== "string" || !ORG_FIELD_SLOTS.has(slot)) {
|
|
5297
|
+
errs.push(`${at}.orgField '${rawOrg}' needs an indexSlot in s1\u2013s4 (every end-user read filters on it)`);
|
|
5298
|
+
}
|
|
5299
|
+
}
|
|
5300
|
+
}
|
|
5301
|
+
}
|
|
5302
|
+
const owner = def.ownerField ?? def.owner_field;
|
|
5303
|
+
if (orgField !== null && owner === orgField) {
|
|
5304
|
+
errs.push(`${at}.orgField and ownerField must name different fields (both are '${orgField}')`);
|
|
5305
|
+
}
|
|
5306
|
+
const rrCamel = def.orgReadRoles;
|
|
5307
|
+
const rrSnake = def.org_read_roles;
|
|
5308
|
+
if (rrCamel !== void 0 && rrSnake !== void 0 && JSON.stringify(rrCamel) !== JSON.stringify(rrSnake)) {
|
|
5309
|
+
errs.push(`${at} sets orgReadRoles and org_read_roles to different values \u2014 send one`);
|
|
5310
|
+
}
|
|
5311
|
+
const rr = rrCamel ?? rrSnake;
|
|
5312
|
+
if (rr !== void 0 && rr !== null) {
|
|
5313
|
+
if (!Array.isArray(rr) || rr.some((r) => typeof r !== "string" || !CMS_ROLE_SLUG_RE.test(r))) {
|
|
5314
|
+
errs.push(`${at}.orgReadRoles must be an array of role slugs matching ${CMS_ROLE_SLUG_RE} (got ${JSON.stringify(rr)})`);
|
|
5315
|
+
} else if (rr.length > CMS_ORG_READ_ROLES_MAX) {
|
|
5316
|
+
errs.push(`${at}.orgReadRoles has ${rr.length} entries \u2014 at most ${CMS_ORG_READ_ROLES_MAX}`);
|
|
5317
|
+
} else if (rr.length > 0 && orgField === null) {
|
|
5318
|
+
errs.push(`${at}.orgReadRoles needs orgField (it gates reads of a workspace-scoped collection)`);
|
|
5319
|
+
}
|
|
5320
|
+
}
|
|
5321
|
+
return errs;
|
|
5322
|
+
}
|
|
5323
|
+
var asList = (v) => Array.isArray(v) ? v : v === void 0 || v === null ? [] : [v];
|
|
5324
|
+
var stringsOf = (v) => asList(v).filter((s) => typeof s === "string");
|
|
5325
|
+
function workspaceManifestErrors(config, opts = {}) {
|
|
5326
|
+
const errs = [];
|
|
5327
|
+
const rawCollections = config.cms?.collections;
|
|
5328
|
+
const collections = isObj(rawCollections) ? rawCollections : {};
|
|
5329
|
+
const orgCols = /* @__PURE__ */ new Map();
|
|
5330
|
+
for (const [name, def] of Object.entries(collections)) {
|
|
5331
|
+
const of = declaredOrgField(def);
|
|
5332
|
+
if (of) orgCols.set(name, of);
|
|
5333
|
+
}
|
|
5334
|
+
if (orgCols.size === 0) return errs;
|
|
5335
|
+
const features = isObj(config.features) ? config.features : {};
|
|
5336
|
+
const names = [...orgCols.keys()].join(", ");
|
|
5337
|
+
const auth = isObj(features.auth) ? features.auth : {};
|
|
5338
|
+
const oc = isObj(auth.orgClaims) ? auth.orgClaims : {};
|
|
5339
|
+
if (opts.checkAuth !== false && (oc.enabled !== true || oc.activeOrg !== true)) {
|
|
5340
|
+
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`);
|
|
5341
|
+
}
|
|
5342
|
+
const cms = isObj(features.cms) ? features.cms : {};
|
|
5343
|
+
for (const [rule, r] of Object.entries(isObj(cms.cdc) ? cms.cdc : {})) {
|
|
5344
|
+
if (isObj(r) && typeof r.collection === "string" && orgCols.has(r.collection)) {
|
|
5345
|
+
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`);
|
|
5346
|
+
}
|
|
5347
|
+
}
|
|
5348
|
+
const vs = isObj(features["vector-search"]) ? features["vector-search"] : {};
|
|
5349
|
+
asList(vs.sync).forEach((s, i) => {
|
|
5350
|
+
if (isObj(s) && typeof s.cmsCollection === "string" && orgCols.has(s.cmsCollection)) {
|
|
5351
|
+
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`);
|
|
5352
|
+
}
|
|
5353
|
+
});
|
|
5354
|
+
for (const [rmName, rm] of Object.entries(isObj(cms.readModels) ? cms.readModels : {})) {
|
|
5355
|
+
if (!isObj(rm) || typeof rm.collection !== "string") continue;
|
|
5356
|
+
const srcOrg = orgCols.get(rm.collection);
|
|
5357
|
+
const to = isObj(rm.materialize) ? rm.materialize.to : void 0;
|
|
5358
|
+
if (!srcOrg || typeof to !== "string") continue;
|
|
5359
|
+
const tOrg = orgCols.get(to);
|
|
5360
|
+
const spec = isObj(rm.spec) ? rm.spec : {};
|
|
5361
|
+
const groups = [...stringsOf(spec.groupBy), ...stringsOf(spec.partitionBy)];
|
|
5362
|
+
if (tOrg !== srcOrg || !groups.includes(srcOrg)) {
|
|
5363
|
+
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`);
|
|
5364
|
+
}
|
|
5365
|
+
}
|
|
5366
|
+
const orgs = isObj(features.orgs) ? features.orgs : {};
|
|
5367
|
+
const longRoles = new Set(asList(orgs.roles).map((r) => isObj(r) ? r.role_key : void 0).filter((k) => typeof k === "string" && k.length > 32));
|
|
5368
|
+
for (const [name] of orgCols) {
|
|
5369
|
+
const def = collections[name];
|
|
5370
|
+
const gate = /* @__PURE__ */ new Set([
|
|
5371
|
+
...stringsOf(def.orgReadRoles ?? def.org_read_roles),
|
|
5372
|
+
...stringsOf(def.writeRoles ?? def.write_roles)
|
|
5373
|
+
]);
|
|
5374
|
+
for (const f of Object.values(isObj(def.fields) ? def.fields : {})) {
|
|
5375
|
+
if (isObj(f)) for (const r of stringsOf(f.readRoles ?? f.read_roles)) gate.add(r);
|
|
5376
|
+
}
|
|
5377
|
+
for (const r of gate) {
|
|
5378
|
+
if (r.length > 32 || longRoles.has(r)) {
|
|
5379
|
+
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)`);
|
|
5380
|
+
}
|
|
5381
|
+
}
|
|
5382
|
+
}
|
|
5383
|
+
return errs;
|
|
5384
|
+
}
|
|
5106
5385
|
|
|
5107
5386
|
// src/lib.ts
|
|
5108
5387
|
var MAX_FN_BINDINGS = 8;
|
|
@@ -5169,9 +5448,9 @@ function lowerAllTriggerBindings(def, name) {
|
|
|
5169
5448
|
return bindings;
|
|
5170
5449
|
}
|
|
5171
5450
|
var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
|
|
5172
|
-
var VXIL_CONFIG_PKG_VERSION = "0.
|
|
5173
|
-
var VXIL_SDK_PKG_VERSION = "0.
|
|
5174
|
-
var VXIL_CLI_PKG_VERSION = "0.
|
|
5451
|
+
var VXIL_CONFIG_PKG_VERSION = "0.18.0";
|
|
5452
|
+
var VXIL_SDK_PKG_VERSION = "0.25.0";
|
|
5453
|
+
var VXIL_CLI_PKG_VERSION = "0.25.0";
|
|
5175
5454
|
function ensureScaffoldPackageJson(cwd, opts = {}) {
|
|
5176
5455
|
const file = resolve(cwd, "package.json");
|
|
5177
5456
|
const wanted = {
|
|
@@ -5335,6 +5614,8 @@ async function loadVxilConfig(cwd = process.cwd()) {
|
|
|
5335
5614
|
throw new Error(`${found}: \`features\` must be an object`);
|
|
5336
5615
|
}
|
|
5337
5616
|
if (typeof cfg.apiVersion === "string") loadedConfigApiVersion = cfg.apiVersion;
|
|
5617
|
+
const wsErrs = workspaceManifestErrors(cfg);
|
|
5618
|
+
if (wsErrs.length) throw new Error(`${found}: ${wsErrs.join("; ")}`);
|
|
5338
5619
|
return cfg;
|
|
5339
5620
|
}
|
|
5340
5621
|
function diffManifest(prev, next, prefix = "") {
|
|
@@ -5353,6 +5634,19 @@ function diffManifest(prev, next, prefix = "") {
|
|
|
5353
5634
|
}
|
|
5354
5635
|
return out;
|
|
5355
5636
|
}
|
|
5637
|
+
var ACTIVE_ORG_MIN_SDK = "0.25.0";
|
|
5638
|
+
function activeOrgEnableWarning(feature, changes) {
|
|
5639
|
+
if (feature !== "auth") return null;
|
|
5640
|
+
const target = ["orgClaims", "activeOrg"];
|
|
5641
|
+
const at = (v, rest2) => rest2.reduce((o, k) => o && typeof o === "object" && !Array.isArray(o) ? o[k] : void 0, v);
|
|
5642
|
+
const turnsOn = changes.some((c) => {
|
|
5643
|
+
const segs = c.path === "" ? [] : c.path.split(".");
|
|
5644
|
+
if (segs.length > target.length || segs.some((k, i) => k !== target[i])) return false;
|
|
5645
|
+
const rest2 = target.slice(segs.length);
|
|
5646
|
+
return at(c.to, rest2) === true && at(c.from, rest2) !== true;
|
|
5647
|
+
});
|
|
5648
|
+
return turnsOn ? `auth orgClaims.activeOrg turns on: apps must use @vxil/sdk ${ACTIVE_ORG_MIN_SDK} or later \u2014 older SDKs do not recover from 401 session_stale after an org membership or role change (see guide 06, orgs)` : null;
|
|
5649
|
+
}
|
|
5356
5650
|
function formatDiff(changes) {
|
|
5357
5651
|
if (!changes.length) return " (no changes)";
|
|
5358
5652
|
return changes.map(({ path, from, to }) => {
|
|
@@ -5415,7 +5709,6 @@ async function mapPool(items, limit, fn) {
|
|
|
5415
5709
|
}
|
|
5416
5710
|
function assembleApplyBundle(cfg, fnSources) {
|
|
5417
5711
|
const { functions: _fnFeature, ...configFeatures } = cfg.features;
|
|
5418
|
-
void _fnFeature;
|
|
5419
5712
|
const functions = Object.entries(cfg.functions ?? {}).map(([name, def]) => {
|
|
5420
5713
|
const source = fnSources[name];
|
|
5421
5714
|
if (source === void 0) throw new Error(`functions: no bundled source for '${name}'`);
|
|
@@ -5434,8 +5727,11 @@ function assembleApplyBundle(cfg, fnSources) {
|
|
|
5434
5727
|
...limits ? { limits } : {}
|
|
5435
5728
|
};
|
|
5436
5729
|
});
|
|
5730
|
+
const fnFeature = _fnFeature;
|
|
5731
|
+
const features = { ...configFeatures };
|
|
5732
|
+
if (fnFeature && typeof fnFeature === "object") features.functions = { logDrain: fnFeature.logDrain ?? null };
|
|
5437
5733
|
return {
|
|
5438
|
-
...Object.keys(
|
|
5734
|
+
...Object.keys(features).length ? { features } : {},
|
|
5439
5735
|
...cfg.cms?.collections && Object.keys(cfg.cms.collections).length ? { cms: { collections: cfg.cms.collections } } : {},
|
|
5440
5736
|
...functions.length ? { functions } : {}
|
|
5441
5737
|
};
|
|
@@ -5593,6 +5889,40 @@ async function planOrPush({ api, features, apply, allowDestructive }) {
|
|
|
5593
5889
|
}
|
|
5594
5890
|
return results;
|
|
5595
5891
|
}
|
|
5892
|
+
function sameLogDrain(want, have) {
|
|
5893
|
+
const norm = (d) => {
|
|
5894
|
+
if (!d || typeof d !== "object") return null;
|
|
5895
|
+
const o = d;
|
|
5896
|
+
return JSON.stringify([o.url, String(o.secretRef ?? "").replace(/^secret:/, ""), o.minLevel ?? "warn"]);
|
|
5897
|
+
};
|
|
5898
|
+
return norm(want) === norm(have);
|
|
5899
|
+
}
|
|
5900
|
+
async function planOrPushLogDrain({ api, fnFeature, apply }) {
|
|
5901
|
+
if (!fnFeature || typeof fnFeature !== "object") return null;
|
|
5902
|
+
const to = fnFeature.logDrain ?? null;
|
|
5903
|
+
const cur = await api("GET", "/v1/config/functions");
|
|
5904
|
+
const unavailable = readUnavailable("GET /v1/config/functions", cur);
|
|
5905
|
+
if (unavailable) {
|
|
5906
|
+
if (apply) {
|
|
5907
|
+
throw new Error(`functions.logDrain: cannot read ${unavailable.route} (${unavailable.code ?? unavailable.status}) \u2014 refusing to push against a remote it could not read`);
|
|
5908
|
+
}
|
|
5909
|
+
return { version: 0, from: null, to, changed: false, applied: null, remoteUnavailable: unavailable };
|
|
5910
|
+
}
|
|
5911
|
+
const exists = cur.status === 200;
|
|
5912
|
+
const version = exists ? Number(cur.body.data?.version ?? 0) : 0;
|
|
5913
|
+
const from = exists ? cur.body.data?.manifest?.logDrain ?? null : null;
|
|
5914
|
+
const changed = !sameLogDrain(to, from);
|
|
5915
|
+
let applied = null;
|
|
5916
|
+
if (apply && changed) {
|
|
5917
|
+
const put = await api("PUT", "/v1/config/functions", { logDrain: to }, version > 0 ? { "if-match": String(version) } : {});
|
|
5918
|
+
if (put.status !== 200) {
|
|
5919
|
+
const e = put.body.error ?? {};
|
|
5920
|
+
throw new Error(`push failed for functions.logDrain: ${e.code ?? put.status} ${e.message ?? ""} ${e.hint ?? ""}`);
|
|
5921
|
+
}
|
|
5922
|
+
applied = put.body.data?.version ?? null;
|
|
5923
|
+
}
|
|
5924
|
+
return { version, from, to, changed, applied };
|
|
5925
|
+
}
|
|
5596
5926
|
function classifyRead(status, code) {
|
|
5597
5927
|
if (status === 200) return "ok";
|
|
5598
5928
|
if (status === 404) return "empty";
|
|
@@ -8126,6 +8456,35 @@ function egressDecision(targetUrl, allowHosts = []) {
|
|
|
8126
8456
|
return { allow: false, reason: `not-allowlisted:${host}` };
|
|
8127
8457
|
}
|
|
8128
8458
|
|
|
8459
|
+
// ../runtime/src/logDrain.ts
|
|
8460
|
+
var LOG_DRAIN_SECRET_NAME_RE = /^[a-z0-9_]{1,64}$/;
|
|
8461
|
+
var LOG_DRAIN_LEVELS = /* @__PURE__ */ new Set(["debug", "info", "log", "warn", "error"]);
|
|
8462
|
+
var LOG_DRAIN_SHAPE_HINT = 'logDrain needs { url: "https://\u2026", secretRef: "<functions secret name>", minLevel?: "debug"|"info"|"log"|"warn"|"error" } (or null to remove it)';
|
|
8463
|
+
function logDrainUrlError(url) {
|
|
8464
|
+
let host;
|
|
8465
|
+
try {
|
|
8466
|
+
host = new URL(url).hostname;
|
|
8467
|
+
} catch {
|
|
8468
|
+
return "logDrain.url is not a URL";
|
|
8469
|
+
}
|
|
8470
|
+
const d = egressDecision(url, [host]);
|
|
8471
|
+
if (!d.allow) return `logDrain.url is refused by the egress guard (${d.reason})`;
|
|
8472
|
+
if (d.reason === "vxil-api") return "logDrain.url must be your own endpoint, not the vxil API";
|
|
8473
|
+
return null;
|
|
8474
|
+
}
|
|
8475
|
+
function logDrainShapeError(drain) {
|
|
8476
|
+
if (drain === null) return null;
|
|
8477
|
+
const d = drain;
|
|
8478
|
+
if (!d || typeof d !== "object" || Array.isArray(d) || typeof d.url !== "string" || typeof d.secretRef !== "string") {
|
|
8479
|
+
return LOG_DRAIN_SHAPE_HINT;
|
|
8480
|
+
}
|
|
8481
|
+
if (!LOG_DRAIN_SECRET_NAME_RE.test(d.secretRef.replace(/^secret:/, ""))) {
|
|
8482
|
+
return `logDrain.secretRef must be a functions secret name matching ${LOG_DRAIN_SECRET_NAME_RE.source}`;
|
|
8483
|
+
}
|
|
8484
|
+
if (d.minLevel !== void 0 && !LOG_DRAIN_LEVELS.has(d.minLevel)) return LOG_DRAIN_SHAPE_HINT;
|
|
8485
|
+
return logDrainUrlError(d.url);
|
|
8486
|
+
}
|
|
8487
|
+
|
|
8129
8488
|
// ../runtime/src/jobsCallbackSig.ts
|
|
8130
8489
|
var JOBS_SIG_HEADER = "X-Vxil-Jobs-Signature";
|
|
8131
8490
|
function signedString(runId, tenantId, issuedAt, bodyHashHex) {
|
|
@@ -8309,7 +8668,8 @@ var DRIFT_SECTION = {
|
|
|
8309
8668
|
policies: "policy",
|
|
8310
8669
|
subscriptions: "subscription",
|
|
8311
8670
|
templates: "template",
|
|
8312
|
-
roles: "role"
|
|
8671
|
+
roles: "role",
|
|
8672
|
+
collections: "search-collection"
|
|
8313
8673
|
};
|
|
8314
8674
|
function declaredDriftKey(datum, key) {
|
|
8315
8675
|
return `api:${DRIFT_SECTION[datum]}:${key}`;
|
|
@@ -8318,7 +8678,8 @@ var DATUM_READ_SCOPE = {
|
|
|
8318
8678
|
policies: "ratelimits:read",
|
|
8319
8679
|
subscriptions: "webhooks:read",
|
|
8320
8680
|
templates: "ai:read",
|
|
8321
|
-
roles: "orgs:read"
|
|
8681
|
+
roles: "orgs:read",
|
|
8682
|
+
collections: "vector-search:read"
|
|
8322
8683
|
};
|
|
8323
8684
|
function declaredApiStateBlocks(features) {
|
|
8324
8685
|
const out = [];
|
|
@@ -8357,7 +8718,8 @@ var ROUTE = {
|
|
|
8357
8718
|
policies: "/v1/rate-limits/policies",
|
|
8358
8719
|
subscriptions: "/v1/webhooks/subscriptions",
|
|
8359
8720
|
templates: "/v1/ai/templates",
|
|
8360
|
-
roles: "/v1/orgs/roles"
|
|
8721
|
+
roles: "/v1/orgs/roles",
|
|
8722
|
+
collections: "/v1/search/collections"
|
|
8361
8723
|
};
|
|
8362
8724
|
function listOf(v) {
|
|
8363
8725
|
return Array.isArray(v) ? v : [];
|
|
@@ -8422,6 +8784,14 @@ async function readOnlyPlan(api, block, allowDestructive) {
|
|
|
8422
8784
|
s.updated.push(...plan.update.map((u) => u.role.role_key));
|
|
8423
8785
|
s.unchanged.push(...plan.unchanged);
|
|
8424
8786
|
destructive([], plan.undeclared);
|
|
8787
|
+
} else if (datum === "collections") {
|
|
8788
|
+
const plan = planSearchCollections(manifest.collections, listOf(data2.collections));
|
|
8789
|
+
s.created.push(...plan.create.map((c) => c.collection));
|
|
8790
|
+
for (const u of plan.update) (u.reembed && !allowDestructive ? s.pending_destructive : s.updated).push(u.body.collection);
|
|
8791
|
+
s.unchanged.push(...plan.unchanged);
|
|
8792
|
+
s.undeclared.push(...plan.undeclared);
|
|
8793
|
+
for (const i of plan.immutable) s.failed.push({ key: i.collection, error: searchCollectionImmutableError(i) });
|
|
8794
|
+
for (const r of plan.refused) s.failed.push({ key: r.collection, error: r.error });
|
|
8425
8795
|
} else {
|
|
8426
8796
|
const hashed = await Promise.all(manifest.templates.map(async (t) => ({
|
|
8427
8797
|
template: t.template,
|
|
@@ -9055,6 +9425,12 @@ function writeRolesWidens(declared, remote) {
|
|
|
9055
9425
|
if (declared.length === 0) return true;
|
|
9056
9426
|
return declared.some((r) => !remote.includes(r));
|
|
9057
9427
|
}
|
|
9428
|
+
function orgFieldWidens(declared, remote) {
|
|
9429
|
+
return remote !== null && declared !== remote;
|
|
9430
|
+
}
|
|
9431
|
+
function orgReadRolesWidens(declared, remote) {
|
|
9432
|
+
return writeRolesWidens(declared, remote);
|
|
9433
|
+
}
|
|
9058
9434
|
function endUserAccessOf(v) {
|
|
9059
9435
|
return v === "read" || v === "none" ? v : "readwrite";
|
|
9060
9436
|
}
|
|
@@ -9151,6 +9527,9 @@ function remoteCollectionOf(c) {
|
|
|
9151
9527
|
public: c.public === true,
|
|
9152
9528
|
endUserAccess: endUserAccessOf(c.end_user_access),
|
|
9153
9529
|
writeRoles: writeRolesOf(c.write_roles),
|
|
9530
|
+
orgField: typeof c.org_field === "string" && c.org_field.length > 0 ? c.org_field : null,
|
|
9531
|
+
orgReadRoles: writeRolesOf(c.org_read_roles),
|
|
9532
|
+
retainDays: typeof c.retain_days === "number" ? c.retain_days : null,
|
|
9154
9533
|
actions: normalizeActions(c.actions),
|
|
9155
9534
|
fields: Array.isArray(c.fields) ? c.fields : []
|
|
9156
9535
|
};
|
|
@@ -9165,6 +9544,9 @@ function pulledCollectionBlock(c) {
|
|
|
9165
9544
|
...rc.public ? { public: true } : {},
|
|
9166
9545
|
...rc.endUserAccess !== "readwrite" ? { endUserAccess: rc.endUserAccess } : {},
|
|
9167
9546
|
...rc.writeRoles.length ? { writeRoles: rc.writeRoles } : {},
|
|
9547
|
+
...rc.orgField ? { orgField: rc.orgField } : {},
|
|
9548
|
+
...rc.orgReadRoles.length ? { orgReadRoles: rc.orgReadRoles } : {},
|
|
9549
|
+
...rc.retainDays !== null ? { retainDays: rc.retainDays } : {},
|
|
9168
9550
|
...rc.actions.length ? { actions: rc.actions } : {},
|
|
9169
9551
|
fields
|
|
9170
9552
|
};
|
|
@@ -9229,6 +9611,12 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
|
|
|
9229
9611
|
if (wr !== void 0 && (!Array.isArray(wr) || wr.some((r) => typeof r !== "string"))) {
|
|
9230
9612
|
throw new Error(`cms.collections.${name}.writeRoles must be an array of role slugs (got ${JSON.stringify(wr)})`);
|
|
9231
9613
|
}
|
|
9614
|
+
const wsErrs = workspaceCollectionErrors(name, def);
|
|
9615
|
+
if (wsErrs.length) throw new Error(wsErrs[0]);
|
|
9616
|
+
const rd = def.retainDays;
|
|
9617
|
+
if (!isDeclaredRetainDays(rd)) {
|
|
9618
|
+
throw new Error(`cms.collections.${name}.retainDays must be an integer 1..3650 (days) or null (got ${JSON.stringify(rd)})`);
|
|
9619
|
+
}
|
|
9232
9620
|
}
|
|
9233
9621
|
const read = await readRemote2(api);
|
|
9234
9622
|
if (read.unavailable) {
|
|
@@ -9409,6 +9797,63 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
|
|
|
9409
9797
|
});
|
|
9410
9798
|
}
|
|
9411
9799
|
}
|
|
9800
|
+
const declaredOrg = def.orgField ?? null;
|
|
9801
|
+
const declaredOrgRoles = writeRolesOf(def.orgReadRoles);
|
|
9802
|
+
const orgSteps = [];
|
|
9803
|
+
if (declaredOrg !== rc.orgField) {
|
|
9804
|
+
const widens = orgFieldWidens(declaredOrg, rc.orgField);
|
|
9805
|
+
if (!widens || allowDestructive) {
|
|
9806
|
+
orgSteps.push({
|
|
9807
|
+
collection: name,
|
|
9808
|
+
kind: "set-org-field",
|
|
9809
|
+
detail: `${name} orgField \u2192 ${declaredOrg === null ? "none" : `'${declaredOrg}'`}${rc.orgField ? ` (was '${rc.orgField}')` : ""}` + (widens ? " \u2014 WIDENS end-user access to workspace rows (full-config reconciliation)" : " (workspace scoping, narrows)")
|
|
9810
|
+
});
|
|
9811
|
+
} else {
|
|
9812
|
+
collChanges.push({
|
|
9813
|
+
collection: name,
|
|
9814
|
+
kind: "warn",
|
|
9815
|
+
detail: `${name}: orgField '${rc.orgField}' is set remotely but ${declaredOrg === null ? "absent" : `'${declaredOrg}'`} in config \u2014 that WIDENS which end users reach the rows, so it is NOT applied. Declare the remote orgField to keep it, or re-run with --allow-destructive.`
|
|
9816
|
+
});
|
|
9817
|
+
}
|
|
9818
|
+
}
|
|
9819
|
+
if (stableStringify(declaredOrgRoles) !== stableStringify(rc.orgReadRoles)) {
|
|
9820
|
+
const widens = orgReadRolesWidens(declaredOrgRoles, rc.orgReadRoles);
|
|
9821
|
+
const fmt = (r) => r.length ? `[${r.join(", ")}]` : "none (any member)";
|
|
9822
|
+
if (!widens || allowDestructive) {
|
|
9823
|
+
const step = {
|
|
9824
|
+
collection: name,
|
|
9825
|
+
kind: "set-org-read-roles",
|
|
9826
|
+
detail: `${name} orgReadRoles \u2192 ${fmt(declaredOrgRoles)} (was ${fmt(rc.orgReadRoles)})` + (widens ? " \u2014 WIDENS who may read (full-config reconciliation)" : " (narrows end-user reads)")
|
|
9827
|
+
};
|
|
9828
|
+
if (declaredOrg === null) orgSteps.unshift(step);
|
|
9829
|
+
else orgSteps.push(step);
|
|
9830
|
+
} else {
|
|
9831
|
+
collChanges.push({
|
|
9832
|
+
collection: name,
|
|
9833
|
+
kind: "warn",
|
|
9834
|
+
detail: `${name}: orgReadRoles are ${fmt(rc.orgReadRoles)} remotely but ${fmt(declaredOrgRoles)} in config \u2014 that WIDENS which workspace members may read, so it is NOT applied. Declare the remote roles to keep them, or re-run with --allow-destructive to widen.`
|
|
9835
|
+
});
|
|
9836
|
+
}
|
|
9837
|
+
}
|
|
9838
|
+
collChanges.push(...orgSteps);
|
|
9839
|
+
const declaredRetain = def.retainDays;
|
|
9840
|
+
if (declaredRetain !== void 0 && declaredRetain !== rc.retainDays) {
|
|
9841
|
+
const fmtDays = (d) => d === null ? "off (kept until deleted)" : `${d} days`;
|
|
9842
|
+
const shortens = retainDaysShortens(declaredRetain, rc.retainDays);
|
|
9843
|
+
if (!shortens || allowDestructive) {
|
|
9844
|
+
collChanges.push({
|
|
9845
|
+
collection: name,
|
|
9846
|
+
kind: "set-retain-days",
|
|
9847
|
+
detail: `${name} retainDays \u2192 ${fmtDays(declaredRetain)} (was ${fmtDays(rc.retainDays)})` + (shortens ? " \u2014 the nightly sweep soft-deletes items older than that" : "")
|
|
9848
|
+
});
|
|
9849
|
+
} else {
|
|
9850
|
+
collChanges.push({
|
|
9851
|
+
collection: name,
|
|
9852
|
+
kind: "warn",
|
|
9853
|
+
detail: `${name}: retainDays is ${fmtDays(rc.retainDays)} remotely but ${fmtDays(declaredRetain)} in config \u2014 that makes the nightly sweep soft-delete existing items, so it is NOT applied. Re-run with --allow-destructive to apply it.`
|
|
9854
|
+
});
|
|
9855
|
+
}
|
|
9856
|
+
}
|
|
9412
9857
|
const declaredActions = normalizeActions(def.actions);
|
|
9413
9858
|
if (stableStringify(declaredActions) !== stableStringify(rc.actions)) {
|
|
9414
9859
|
collChanges.push({
|
|
@@ -9484,6 +9929,10 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
|
|
|
9484
9929
|
if (access !== "readwrite") body.end_user_access = access;
|
|
9485
9930
|
const roles = writeRolesOf(def.writeRoles);
|
|
9486
9931
|
if (roles.length > 0) body.write_roles = roles;
|
|
9932
|
+
if (def.orgField) body.org_field = def.orgField;
|
|
9933
|
+
const orgRoles = writeRolesOf(def.orgReadRoles);
|
|
9934
|
+
if (orgRoles.length > 0) body.org_read_roles = orgRoles;
|
|
9935
|
+
if (typeof def.retainDays === "number") body.retain_days = def.retainDays;
|
|
9487
9936
|
if (def.actions && def.actions.length > 0) body.actions = normalizeActions(def.actions);
|
|
9488
9937
|
const res = await api("POST", "/v1/cms/collections", body);
|
|
9489
9938
|
if (res.status !== 201 && res.status !== 200 && res.body.error?.code !== "collection_exists") {
|
|
@@ -9543,6 +9992,28 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
|
|
|
9543
9992
|
throw new Error(`cms: set writeRoles on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
|
|
9544
9993
|
}
|
|
9545
9994
|
applied++;
|
|
9995
|
+
} else if (c.kind === "set-org-field" || c.kind === "set-org-read-roles") {
|
|
9996
|
+
const def = collections[c.collection];
|
|
9997
|
+
const remoteRc = remote.get(c.collection);
|
|
9998
|
+
const isField = c.kind === "set-org-field";
|
|
9999
|
+
const target = isField ? def.orgField ?? null : writeRolesOf(def.orgReadRoles);
|
|
10000
|
+
const widens = isField ? orgFieldWidens(target, remoteRc?.orgField ?? null) : orgReadRolesWidens(target, remoteRc?.orgReadRoles ?? []);
|
|
10001
|
+
const body = isField ? { org_field: target } : { org_read_roles: target };
|
|
10002
|
+
if (widens) body.allow_destructive = true;
|
|
10003
|
+
const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, body);
|
|
10004
|
+
if (res.status !== 200 && res.status !== 201) {
|
|
10005
|
+
const e = res.body.error ?? {};
|
|
10006
|
+
throw new Error(`cms: set ${isField ? "orgField" : "orgReadRoles"} on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
|
|
10007
|
+
}
|
|
10008
|
+
applied++;
|
|
10009
|
+
} else if (c.kind === "set-retain-days") {
|
|
10010
|
+
const target = collections[c.collection].retainDays ?? null;
|
|
10011
|
+
const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { retain_days: target });
|
|
10012
|
+
if (res.status !== 200 && res.status !== 201) {
|
|
10013
|
+
const e = res.body.error ?? {};
|
|
10014
|
+
throw new Error(`cms: set retainDays on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
|
|
10015
|
+
}
|
|
10016
|
+
applied++;
|
|
9546
10017
|
} else if (c.kind === "set-actions") {
|
|
9547
10018
|
const target = normalizeActions(collections[c.collection].actions);
|
|
9548
10019
|
const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { actions: target });
|
|
@@ -9608,7 +10079,7 @@ function formatCmsChanges(changes) {
|
|
|
9608
10079
|
if (c.kind === "destructive") return ` ! ${c.detail}`;
|
|
9609
10080
|
if (c.kind === "warn") return ` \u26A0 ${c.detail}`;
|
|
9610
10081
|
if (c.kind === "reindex") return ` \u21BB ${c.detail}`;
|
|
9611
|
-
if (c.kind === "alter-flags" || c.kind === "set-owner-field" || c.kind === "set-end-user-access" || c.kind === "set-write-roles") return ` ~ ${c.detail}`;
|
|
10082
|
+
if (c.kind === "alter-flags" || c.kind === "set-owner-field" || c.kind === "set-end-user-access" || c.kind === "set-write-roles" || c.kind === "set-retain-days") return ` ~ ${c.detail}`;
|
|
9612
10083
|
return ` + ${c.detail}`;
|
|
9613
10084
|
}).join("\n");
|
|
9614
10085
|
}
|
|
@@ -12503,7 +12974,11 @@ var TOOLS = [
|
|
|
12503
12974
|
description: "Explicit locale wins; otherwise the recipient's stored `locale` attribute (users_upsert attributes.locale), then the tenant's config.defaultLocale."
|
|
12504
12975
|
},
|
|
12505
12976
|
send_at: { type: "string", description: "future ISO instant, \u2264 30 d out (mutually exclusive with delay_seconds)" },
|
|
12506
|
-
delay_seconds: { type: "number", minimum: 0, maximum: 2592e3 }
|
|
12977
|
+
delay_seconds: { type: "number", minimum: 0, maximum: 2592e3 },
|
|
12978
|
+
to_email: { type: "string", description: "Deliver to this address instead of the user's stored email (an invitee with no account). user_id must still exist; suppressions are checked against this address." },
|
|
12979
|
+
from_name: { type: "string", maxLength: 64, description: "This send's sender name; default config fromName." },
|
|
12980
|
+
from_local_part: { type: "string", pattern: "^[a-z0-9._-]{1,40}$", description: "This send's from-address local part; the domain is always the config fromEmail domain." },
|
|
12981
|
+
reply_to: { type: "string", description: "This send's Reply-To address; default config replyTo." }
|
|
12507
12982
|
},
|
|
12508
12983
|
required: ["user_id", "template", "data"]
|
|
12509
12984
|
},
|
|
@@ -12530,6 +13005,10 @@ var TOOLS = [
|
|
|
12530
13005
|
channel: { type: "string", enum: ["email", "inbox", "both"] },
|
|
12531
13006
|
send_at: { type: "string", description: "future ISO instant (\u2264 30 d)" },
|
|
12532
13007
|
delay_seconds: { type: "number", minimum: 0, maximum: 2592e3 },
|
|
13008
|
+
to_email: { type: "string" },
|
|
13009
|
+
from_name: { type: "string", maxLength: 64 },
|
|
13010
|
+
from_local_part: { type: "string", pattern: "^[a-z0-9._-]{1,40}$" },
|
|
13011
|
+
reply_to: { type: "string" },
|
|
12533
13012
|
idempotency_key: { type: "string" }
|
|
12534
13013
|
},
|
|
12535
13014
|
required: ["user_id", "template", "data"]
|
|
@@ -13212,14 +13691,19 @@ var TOOLS = [
|
|
|
13212
13691
|
{
|
|
13213
13692
|
name: "files_get_download_url",
|
|
13214
13693
|
feature: "files",
|
|
13215
|
-
description:
|
|
13694
|
+
description: 'Mint a short-lived presigned GET URL for an available object. disposition "inline" opens a PDF/image/ICO/audio/video in the browser (html, svg, js and other types always download); filename sets the saved name.',
|
|
13216
13695
|
inputSchema: {
|
|
13217
13696
|
type: "object",
|
|
13218
|
-
properties: {
|
|
13697
|
+
properties: {
|
|
13698
|
+
object_id: { type: "string" },
|
|
13699
|
+
disposition: { type: "string", enum: ["inline", "attachment"], description: "Omit for the default (raster images inline, everything else attachment)." },
|
|
13700
|
+
filename: { type: "string", description: "Download filename (sanitised server-side, at most 200 characters). Default: the upload filename." }
|
|
13701
|
+
},
|
|
13219
13702
|
required: ["object_id"]
|
|
13220
13703
|
},
|
|
13221
13704
|
method: "GET",
|
|
13222
|
-
path: (a) => `/v1/files/${encodeURIComponent(String(a.object_id))}/download-url
|
|
13705
|
+
path: (a) => `/v1/files/${encodeURIComponent(String(a.object_id))}/download-url`,
|
|
13706
|
+
queryArgs: ["disposition", "filename"]
|
|
13223
13707
|
},
|
|
13224
13708
|
{
|
|
13225
13709
|
name: "files_get_download_urls",
|
|
@@ -13355,7 +13839,7 @@ var TOOLS = [
|
|
|
13355
13839
|
{
|
|
13356
13840
|
name: "cms_list_collections",
|
|
13357
13841
|
feature: "cms",
|
|
13358
|
-
description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against). Each field row carries its attributes \u2014 required, validation, index_slot, relation_to, computed/compute, is_unique, on_delete, read_roles (the end-user org roles allowed to READ that field; null = ungated), and on an equality-indexed field indexed: true + index_ready (equality filters on it are index-served once ready; until then, and on unindexed unslotted fields, they are a bounded scan \u2014 same results). A field with read_roles is omitted from end-user reads without a matching role and is never served on the public lane, so a missing key in an item's data may be a read gate rather than an unset value. Each collection row also carries end_user_access: 'readwrite' (default), 'read' (a verified end user \u2014 a thin-client key with a session \u2014 may read but every write answers 403 server_only) or 'none' (end-user reads and writes are 403 server_only), and write_roles: the roles a verified end user must hold to WRITE the collection (null = ungated; without one every end-user write answers 403 role_required, reads are unaffected); server keys are never affected. retain_days (null = keep) is the collection's retention: the nightly platform sweep soft-deletes live items created more than that many days ago.",
|
|
13842
|
+
description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against). Each field row carries its attributes \u2014 required, validation, index_slot, relation_to, computed/compute, is_unique, on_delete, read_roles (the end-user org roles allowed to READ that field; null = ungated), and on an equality-indexed field indexed: true + index_ready (equality filters on it are index-served once ready; until then, and on unindexed unslotted fields, they are a bounded scan \u2014 same results). A field with read_roles is omitted from end-user reads without a matching role and is never served on the public lane, so a missing key in an item's data may be a read gate rather than an unset value. Each collection row also carries end_user_access: 'readwrite' (default), 'read' (a verified end user \u2014 a thin-client key with a session \u2014 may read but every write answers 403 server_only) or 'none' (end-user reads and writes are 403 server_only), and write_roles: the roles a verified end user must hold to WRITE the collection (null = ungated; without one every end-user write answers 403 role_required, reads are unaffected); server keys are never affected. retain_days (null = keep) is the collection's retention: the nightly platform sweep soft-deletes live items created more than that many days ago. org_field (null = none) makes the collection workspace-scoped: a verified end user reads and writes only rows of the session's active org (no active org \u2192 403 org_required) and write_roles match the active-org role; org_read_roles (null = any member) gates reads by that role.",
|
|
13359
13843
|
inputSchema: { type: "object", properties: {} },
|
|
13360
13844
|
method: "GET",
|
|
13361
13845
|
path: "/v1/cms/collections"
|
|
@@ -13703,15 +14187,18 @@ var TOOLS = [
|
|
|
13703
14187
|
{
|
|
13704
14188
|
name: "orgs_session_claims",
|
|
13705
14189
|
feature: "orgs",
|
|
13706
|
-
description: "The
|
|
14190
|
+
description: "The org snapshot \u2014 { org_id, role, perms[] } \u2014 auth embeds as the session-JWT `org` claim when the tenant enables auth config orgClaims. Without org_id it resolves the user's MOST RECENT membership (by joined_at): the claim a session carries with plain orgClaims, and the one a first sign-in stores with orgClaims.activeOrg. With orgClaims.activeOrg a session then acts in the org it stored or switched to (POST /v1/auth/sessions/refresh with org_id), not necessarily the most recent one \u2014 pass org_id to read that org's claim. perms = the role's effective permission set (custom catalog entry wins, capped at 32). Not a member (or no membership) \u2192 org_id null. EVENTUAL-CONSISTENT: JWTs recompute it only at mint/refresh \u2014 use orgs_check_permission for revocation-grade authz. Read-only \u2014 needs orgs:read.",
|
|
13707
14191
|
inputSchema: {
|
|
13708
14192
|
type: "object",
|
|
13709
|
-
properties: {
|
|
14193
|
+
properties: {
|
|
14194
|
+
user_id: { type: "string", description: "The end_user_id to resolve." },
|
|
14195
|
+
org_id: { type: "string", description: "Optional: resolve the claim for THIS org (the active org a session picked) instead of the most recent membership." }
|
|
14196
|
+
},
|
|
13710
14197
|
required: ["user_id"]
|
|
13711
14198
|
},
|
|
13712
14199
|
method: "GET",
|
|
13713
14200
|
path: "/v1/orgs/session-claims",
|
|
13714
|
-
queryArgs: ["user_id"]
|
|
14201
|
+
queryArgs: ["user_id", "org_id"]
|
|
13715
14202
|
},
|
|
13716
14203
|
{
|
|
13717
14204
|
name: "orgs_define_role",
|
|
@@ -13894,7 +14381,7 @@ var TOOLS = [
|
|
|
13894
14381
|
{
|
|
13895
14382
|
name: "functions_logs",
|
|
13896
14383
|
feature: "functions",
|
|
13897
|
-
description:
|
|
14384
|
+
description: 'Log records of ONE deployed function, one per invocation (ts, outcome, wall_ms, cpu_ms, console lines, exceptions), oldest first, with `next_since` \u2014 pass it back as `since` to read only newer records (a tail). Without `since`: the most recent records of the last 24 hours. Kept 14 days. Needs functions:read. To forward log lines to your own https endpoint (kept as long as you like), declare features.functions.logDrain { url, secretRef, minLevel } and push it, or PUT /v1/config/functions with { "logDrain": {\u2026} } (null removes it); this tool only reads.',
|
|
13898
14385
|
inputSchema: {
|
|
13899
14386
|
type: "object",
|
|
13900
14387
|
properties: {
|
|
@@ -14065,7 +14552,7 @@ var TOOLS = [
|
|
|
14065
14552
|
{
|
|
14066
14553
|
name: "search_query",
|
|
14067
14554
|
feature: "vector-search",
|
|
14068
|
-
description: "Retrieve the top-k most relevant chunks from a vector-search collection. mode 'hybrid' (default) fuses semantic + keyword via RRF; 'vector' is pure semantic; 'keyword' is lexical. filter is a JSON object
|
|
14555
|
+
description: "Retrieve the top-k most relevant chunks from a vector-search collection. mode 'hybrid' (default) fuses semantic + keyword via RRF; 'vector' is pure semantic; 'keyword' is lexical. filter is a JSON object over top-level chunk metadata keys, ANDed: {key: value} is equality, {key: {$in: [a, b]}} matches any of 1\u201350 values (\u22644 $in keys); any other value is refused 422. Returns ranked results with document/chunk attribution: score is the fused rank value (orders the hits, not a similarity); similarity is the cosine similarity to the query (-1..1, null in keyword mode) \u2014 threshold on similarity. embed_provider names the collection's embedder; 'mock' is the deterministic test embedder (ranks by a hash, not meaning), so tell the user search is not semantic until a real provider is set.",
|
|
14069
14556
|
inputSchema: {
|
|
14070
14557
|
type: "object",
|
|
14071
14558
|
properties: {
|
|
@@ -14073,7 +14560,7 @@ var TOOLS = [
|
|
|
14073
14560
|
query: { type: "string", description: "Natural-language query text." },
|
|
14074
14561
|
top_k: { type: "number", minimum: 1, maximum: 100, default: 10 },
|
|
14075
14562
|
mode: { type: "string", enum: ["hybrid", "vector", "keyword"], default: "hybrid" },
|
|
14076
|
-
filter: { type: "object", additionalProperties: true, description: 'Metadata
|
|
14563
|
+
filter: { type: "object", additionalProperties: true, description: 'Metadata filter, keys ANDed: equality {"user_id":"u_42"} or any-of {"folder":{"$in":["a","b"]}} (1\u201350 values, \u22644 $in keys). null / arrays / other operators are refused 422.' },
|
|
14077
14564
|
rerank: { type: "boolean", description: "Apply the configured BYO reranker (config rerank block) to the fused results before top-k. Fail-open: a provider fault returns the RRF order with reranked:false." }
|
|
14078
14565
|
},
|
|
14079
14566
|
required: ["collection", "query"]
|
|
@@ -14239,7 +14726,7 @@ var TOOLS = [
|
|
|
14239
14726
|
template: { type: "string", description: "Override the answer template (defaults to rag config defaultTemplate)." },
|
|
14240
14727
|
top_k: { type: "number", minimum: 1, maximum: 100, description: "Chunks to retrieve before budgeting." },
|
|
14241
14728
|
mode: { type: "string", enum: ["hybrid", "vector", "keyword"] },
|
|
14242
|
-
filter: { type: "object", additionalProperties: true, description:
|
|
14729
|
+
filter: { type: "object", additionalProperties: true, description: 'Metadata filter applied to retrieval: equality {"key":"value"} or any-of {"key":{"$in":[...]}} (1\u201350 values), keys ANDed.' },
|
|
14243
14730
|
provider: { type: "string" },
|
|
14244
14731
|
model: { type: "string" },
|
|
14245
14732
|
input: { type: "object", additionalProperties: true, description: "Extra template vars beyond {{query}}/{{context}}." },
|
|
@@ -14262,7 +14749,7 @@ var TOOLS = [
|
|
|
14262
14749
|
collection: { type: "string", description: "Collection to search (defaults to rag config defaultCollection)." },
|
|
14263
14750
|
top_k: { type: "number", minimum: 1, maximum: 100, description: "Chunks to retrieve." },
|
|
14264
14751
|
mode: { type: "string", enum: ["hybrid", "vector", "keyword"] },
|
|
14265
|
-
filter: { type: "object", additionalProperties: true, description:
|
|
14752
|
+
filter: { type: "object", additionalProperties: true, description: 'Metadata filter applied to retrieval: equality {"key":"value"} or any-of {"key":{"$in":[...]}} (1\u201350 values), keys ANDed.' },
|
|
14266
14753
|
rerank: { type: "boolean", description: "Forward rerank:true to vector-search's BYO rerank pass (inert until configured there)." },
|
|
14267
14754
|
boosts: { type: "object", additionalProperties: true, description: "Per-metadata-field boost specs: { field: { kind:'recency', halfLifeDays?, weight? } | { kind:'value', weights:{\u2026} } }." },
|
|
14268
14755
|
min_score: { type: "number", description: "Drop hits below this EFFECTIVE (post-boost) score. score is a rank value (about 0.016-0.033 at the top), so a floor above ~0.033 drops every hit; use min_similarity for a relevance threshold." },
|
|
@@ -14848,6 +15335,10 @@ function collectSecretRefs(cfg) {
|
|
|
14848
15335
|
if (m) add({ name: m[1], feature, via: `features.${feature}.${path}` });
|
|
14849
15336
|
}
|
|
14850
15337
|
}
|
|
15338
|
+
const drainRef = cfg.features?.functions?.logDrain?.secretRef;
|
|
15339
|
+
if (typeof drainRef === "string" && drainRef.replace(/^secret:/, "")) {
|
|
15340
|
+
add({ name: drainRef.replace(/^secret:/, ""), feature: "functions", via: "features.functions.logDrain.secretRef" });
|
|
15341
|
+
}
|
|
14851
15342
|
for (const [fn, def] of Object.entries(cfg.functions ?? {})) {
|
|
14852
15343
|
for (const ref of normalizeSecretRefs(def.secrets)) {
|
|
14853
15344
|
const bare = ref.slice("secret:".length);
|
|
@@ -14923,7 +15414,8 @@ var COMPARED_LABELS = [
|
|
|
14923
15414
|
["subscriptions", "subscription"],
|
|
14924
15415
|
["campaigns", "campaign"],
|
|
14925
15416
|
["templates", "template"],
|
|
14926
|
-
["roles", "role"]
|
|
15417
|
+
["roles", "role"],
|
|
15418
|
+
["searchCollections", "search collection"]
|
|
14927
15419
|
];
|
|
14928
15420
|
function formatCompared(c) {
|
|
14929
15421
|
const parts = [];
|
|
@@ -16101,7 +16593,9 @@ var EXTRA_KNOWN_FLAGS = [
|
|
|
16101
16593
|
"include-empty",
|
|
16102
16594
|
"include-password-hashes",
|
|
16103
16595
|
"with-password-hashes",
|
|
16596
|
+
"with-oauth-identities",
|
|
16104
16597
|
"keep-source-id",
|
|
16598
|
+
"keep-timestamps",
|
|
16105
16599
|
"oidc-issuer",
|
|
16106
16600
|
"only",
|
|
16107
16601
|
"pg-schema",
|
|
@@ -16804,7 +17298,8 @@ function inferMapping(source, opts = {}) {
|
|
|
16804
17298
|
if (owner) mapping.userRefColumn = owner.column;
|
|
16805
17299
|
for (const col of t.columns) {
|
|
16806
17300
|
if (col.name === "created_at" || col.name === "updated_at") {
|
|
16807
|
-
mapping.reasons.push(`R13: ${col.name} dropped \u2014 cms manages created_at/updated_at (original values
|
|
17301
|
+
mapping.reasons.push(`R13: ${col.name} dropped \u2014 cms manages created_at/updated_at (original values kept with \`data --keep-timestamps\`)`);
|
|
17302
|
+
mapping.sourceTimestamps = { ...mapping.sourceTimestamps, [col.name === "created_at" ? "created" : "updated"]: col.name };
|
|
16808
17303
|
continue;
|
|
16809
17304
|
}
|
|
16810
17305
|
const isOwnerCol = owner?.column === col.name;
|
|
@@ -17206,6 +17701,32 @@ function storagePatch(row2, fields, resolve8) {
|
|
|
17206
17701
|
function readColumns(mapping, pk, extra = []) {
|
|
17207
17702
|
return [.../* @__PURE__ */ new Set([pk, ...mapping.fields.map((f) => f.source), ...mapping.changeColumn ? [mapping.changeColumn] : [], ...extra])];
|
|
17208
17703
|
}
|
|
17704
|
+
function sourceTimestampColumns(mapping) {
|
|
17705
|
+
if (mapping.sourceTimestamps) return { ...mapping.sourceTimestamps };
|
|
17706
|
+
const out = {};
|
|
17707
|
+
for (const r of mapping.reasons) {
|
|
17708
|
+
const m = /^R13: (created_at|updated_at) dropped/.exec(r);
|
|
17709
|
+
if (m?.[1] === "created_at") out.created = "created_at";
|
|
17710
|
+
if (m?.[1] === "updated_at") out.updated = "updated_at";
|
|
17711
|
+
}
|
|
17712
|
+
return out;
|
|
17713
|
+
}
|
|
17714
|
+
var TS_MIN_MS = 0;
|
|
17715
|
+
var TS_FUTURE_MARGIN_MS = 6e4;
|
|
17716
|
+
var tsIso = (raw, nowMs) => {
|
|
17717
|
+
let t;
|
|
17718
|
+
if (raw instanceof Date) t = raw.getTime();
|
|
17719
|
+
else if (typeof raw === "string" && raw !== "") t = Date.parse(raw);
|
|
17720
|
+
else return null;
|
|
17721
|
+
if (Number.isNaN(t) || t < TS_MIN_MS || t > nowMs + TS_FUTURE_MARGIN_MS) return null;
|
|
17722
|
+
return new Date(t).toISOString();
|
|
17723
|
+
};
|
|
17724
|
+
function sourceTimestampsOf(row2, cols, nowMs = Date.now()) {
|
|
17725
|
+
const created = cols.created ? tsIso(row2[cols.created], nowMs) : null;
|
|
17726
|
+
if (created === null) return void 0;
|
|
17727
|
+
const updated = cols.updated ? tsIso(row2[cols.updated], nowMs) : null;
|
|
17728
|
+
return updated !== null && updated >= created ? { created_at: created, updated_at: updated } : { created_at: created };
|
|
17729
|
+
}
|
|
17209
17730
|
var SCALAR_UNIQUE = /* @__PURE__ */ new Set(["string", "int", "float", "datetime"]);
|
|
17210
17731
|
function buildItem(row2, mapping, state, tableByCollection, resolve8) {
|
|
17211
17732
|
const full = {};
|
|
@@ -17262,7 +17783,18 @@ function holdsContent(stored, item, storageTargets) {
|
|
|
17262
17783
|
return cur === rowHash(v) || storageTargets.has(k) && cur === rowHash(item.hashInput[k]);
|
|
17263
17784
|
});
|
|
17264
17785
|
}
|
|
17265
|
-
async function loadCmsTable({
|
|
17786
|
+
async function loadCmsTable({
|
|
17787
|
+
deps,
|
|
17788
|
+
plan,
|
|
17789
|
+
mapping,
|
|
17790
|
+
adapter,
|
|
17791
|
+
state,
|
|
17792
|
+
save,
|
|
17793
|
+
pkColumn,
|
|
17794
|
+
itemStatus = "published",
|
|
17795
|
+
delta,
|
|
17796
|
+
keepTimestamps = false
|
|
17797
|
+
}) {
|
|
17266
17798
|
const table = mapping.table;
|
|
17267
17799
|
const collection = mapping.collection;
|
|
17268
17800
|
const key = `cms:${table}`;
|
|
@@ -17296,8 +17828,12 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
|
|
|
17296
17828
|
let unhashedSame = 0;
|
|
17297
17829
|
const enc2 = encodeURIComponent(collection);
|
|
17298
17830
|
const start = cursors[key] ?? void 0;
|
|
17831
|
+
const tsCols = keepTimestamps ? sourceTimestampColumns(mapping) : {};
|
|
17832
|
+
if (keepTimestamps && tsCols.created === void 0) {
|
|
17833
|
+
deps.log(`cms ${table}: --keep-timestamps \u2014 the source has no created_at column; items take the time of the import`);
|
|
17834
|
+
}
|
|
17299
17835
|
const readOpts = {
|
|
17300
|
-
columns: readColumns(mapping, pk),
|
|
17836
|
+
columns: readColumns(mapping, pk, [tsCols.created, tsCols.updated].filter((c) => c !== void 0)),
|
|
17301
17837
|
...delta && mapping.changeColumn ? { changedSince: { column: mapping.changeColumn, since: delta.since } } : {}
|
|
17302
17838
|
};
|
|
17303
17839
|
const noteStorage = (sourceId, row2) => {
|
|
@@ -17380,7 +17916,12 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
|
|
|
17380
17916
|
continue;
|
|
17381
17917
|
}
|
|
17382
17918
|
if (deps.dryRun) continue;
|
|
17383
|
-
const
|
|
17919
|
+
const srcTs = keepTimestamps ? sourceTimestampsOf(row2, tsCols) : void 0;
|
|
17920
|
+
const res = await deps.api("POST", `/v1/cms/items/${enc2}`, {
|
|
17921
|
+
data: item.create,
|
|
17922
|
+
status: itemStatus,
|
|
17923
|
+
...srcTs ? { source_timestamps: srcTs } : {}
|
|
17924
|
+
});
|
|
17384
17925
|
if (res.status === 201 || res.status === 200) {
|
|
17385
17926
|
const itemId = res.body.data?.item_id;
|
|
17386
17927
|
if (typeof itemId !== "string") {
|
|
@@ -17565,11 +18106,14 @@ async function loadUsers({ deps, adapter, mapping, state, save, delta = false })
|
|
|
17565
18106
|
// src/migrate/loaders/credentials.ts
|
|
17566
18107
|
var CREDENTIALS_BATCH_MAX = 500;
|
|
17567
18108
|
var KEY = "users:credentials";
|
|
18109
|
+
var IDENTITIES_PER_USER_MAX = 8;
|
|
17568
18110
|
function credentialEntry(row2, withHashes) {
|
|
18111
|
+
const identities = (row2.identities ?? []).slice(0, IDENTITIES_PER_USER_MAX);
|
|
17569
18112
|
return {
|
|
17570
18113
|
id: row2.id,
|
|
17571
18114
|
...withHashes && row2.password_hash ? { password_hash: row2.password_hash } : {},
|
|
17572
|
-
...row2.email_verified ? { email_verified: true } : {}
|
|
18115
|
+
...row2.email_verified ? { email_verified: true } : {},
|
|
18116
|
+
...identities.length > 0 ? { identities } : {}
|
|
17573
18117
|
};
|
|
17574
18118
|
}
|
|
17575
18119
|
async function loadCredentials2({ deps, adapter, state, save, opts, delta = false }) {
|
|
@@ -17582,11 +18126,17 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
|
|
|
17582
18126
|
const sent = hashesFor(state, KEY);
|
|
17583
18127
|
if (cursors[KEY] === null) {
|
|
17584
18128
|
deps.log(delta ? "credentials: incremental pass already complete for this --since window \u2014 skipping" : `credentials: already complete \u2014 skipping (\`data --since <time>\` re-reads it)`);
|
|
18129
|
+
if (opts.withIdentities) {
|
|
18130
|
+
deps.log(" \u26A0 --with-oauth-identities: social links attach only to users the import CREATES \u2014 users already imported by an earlier run are kept as they are and get no links. To carry the links, import into a fresh target (`vxil dev down` / a new project) with --with-password-hashes --with-oauth-identities.");
|
|
18131
|
+
}
|
|
17585
18132
|
return result;
|
|
17586
18133
|
}
|
|
17587
18134
|
const reasons = /* @__PURE__ */ new Map();
|
|
17588
18135
|
let withPassword = 0;
|
|
17589
|
-
|
|
18136
|
+
let withLinks = 0;
|
|
18137
|
+
let linked = 0;
|
|
18138
|
+
const readOpts = opts.withIdentities ? { identities: true } : void 0;
|
|
18139
|
+
for await (const batch of adapter.readCredentials(cursors[KEY] ?? void 0, readOpts)) {
|
|
17590
18140
|
const entries = [];
|
|
17591
18141
|
const digests = [];
|
|
17592
18142
|
for (const row2 of batch.rows) {
|
|
@@ -17598,6 +18148,7 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
|
|
|
17598
18148
|
continue;
|
|
17599
18149
|
}
|
|
17600
18150
|
if (entry.password_hash) withPassword++;
|
|
18151
|
+
if (entry.identities) withLinks++;
|
|
17601
18152
|
entries.push(entry);
|
|
17602
18153
|
digests.push(h);
|
|
17603
18154
|
}
|
|
@@ -17615,6 +18166,7 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
|
|
|
17615
18166
|
return result;
|
|
17616
18167
|
}
|
|
17617
18168
|
const data2 = res.body.data ?? {};
|
|
18169
|
+
linked += typeof data2.identities_linked === "number" ? data2.identities_linked : 0;
|
|
17618
18170
|
const taken = (data2.results ?? []).filter((r) => r.status === "skipped" && r.reason === "id_taken").length;
|
|
17619
18171
|
if (taken > 0) {
|
|
17620
18172
|
result.errors.push(`credentials: ${taken} user id(s) already belong to another vxil project in this environment (user ids are unique across projects) \u2014 most often a rehearsal dev backend that imported the same users. Delete it (\`vxil dev down\`) or the project that holds them, then re-run: the cursor was NOT advanced and the import is idempotent. Left as is, those users would get a new id at sign-up and lose their data.`);
|
|
@@ -17636,10 +18188,13 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
|
|
|
17636
18188
|
if (batch.nextCursor === null) break;
|
|
17637
18189
|
}
|
|
17638
18190
|
if (deps.dryRun) {
|
|
17639
|
-
deps.log(`credentials: DRY-RUN \u2014 would import ${result.read - result.skipped} identit(ies)` + (opts.withHashes ? `, ${withPassword} with a password hash` : " (no hashes \u2014 --with-password-hashes not set)") + (opts.oidcIssuer ? ", each linked to the source issuer for the session hand-over" : "") + "; nothing written");
|
|
18191
|
+
deps.log(`credentials: DRY-RUN \u2014 would import ${result.read - result.skipped} identit(ies)` + (opts.withHashes ? `, ${withPassword} with a password hash` : " (no hashes \u2014 --with-password-hashes not set)") + (opts.oidcIssuer ? ", each linked to the source issuer for the session hand-over" : "") + (opts.withIdentities ? `, ${withLinks} with social sign-in links` : "") + "; nothing written");
|
|
17640
18192
|
} else {
|
|
17641
18193
|
cursors[KEY] = null;
|
|
17642
18194
|
save();
|
|
18195
|
+
if (opts.withIdentities) {
|
|
18196
|
+
deps.log(`credentials: ${linked} social sign-in link(s) created for ${withLinks} user(s) \u2014 a link is made only for an identity this import creates or still holds, and a subject already linked stays put`);
|
|
18197
|
+
}
|
|
17643
18198
|
}
|
|
17644
18199
|
if (reasons.size > 0) {
|
|
17645
18200
|
const list = [...reasons].map(([r, n]) => `${r}=${n}`).join(" ");
|
|
@@ -18716,6 +19271,7 @@ async function runData(deps, plan, adapter, opts = {}) {
|
|
|
18716
19271
|
save: g.save,
|
|
18717
19272
|
...opts.pkColumns?.[table] !== void 0 ? { pkColumn: opts.pkColumns[table] } : {},
|
|
18718
19273
|
...opts.itemStatus !== void 0 ? { itemStatus: opts.itemStatus } : {},
|
|
19274
|
+
...opts.keepTimestamps ? { keepTimestamps: true } : {},
|
|
18719
19275
|
...delta ? { delta } : {}
|
|
18720
19276
|
});
|
|
18721
19277
|
results.push(r);
|
|
@@ -19069,7 +19625,14 @@ async function runMigrateData(io, opts = {}) {
|
|
|
19069
19625
|
if (opts.oidcIssuer !== void 0 && !/^https:\/\/[^\s?#]+$/.test(opts.oidcIssuer)) {
|
|
19070
19626
|
throw new Error(`--oidc-issuer '${opts.oidcIssuer}' must be an https URL with no query or fragment (Supabase: https://<ref>.supabase.co/auth/v1)`);
|
|
19071
19627
|
}
|
|
19072
|
-
|
|
19628
|
+
if (opts.withOauthIdentities && !opts.withPasswordHashes) {
|
|
19629
|
+
throw new Error("--with-oauth-identities needs --with-password-hashes in the same run: the import creates each user once, so a user with both a password and a social link would otherwise lose the password for good");
|
|
19630
|
+
}
|
|
19631
|
+
const credentials = opts.withPasswordHashes || opts.oidcIssuer !== void 0 || opts.withOauthIdentities ? {
|
|
19632
|
+
withHashes: opts.withPasswordHashes === true,
|
|
19633
|
+
...opts.oidcIssuer !== void 0 ? { oidcIssuer: opts.oidcIssuer } : {},
|
|
19634
|
+
...opts.withOauthIdentities ? { withIdentities: true } : {}
|
|
19635
|
+
} : void 0;
|
|
19073
19636
|
const tableNames = new Set(plan.tables.map((t) => t.table));
|
|
19074
19637
|
for (const o of opts.only ?? []) {
|
|
19075
19638
|
if (!DATA_STEPS.has(o) && !tableNames.has(o)) {
|
|
@@ -19104,7 +19667,8 @@ async function runMigrateData(io, opts = {}) {
|
|
|
19104
19667
|
...opts.prune ? { prune: true } : {},
|
|
19105
19668
|
...specs ? { payments: specs } : {},
|
|
19106
19669
|
...credentials ? { credentials } : {},
|
|
19107
|
-
...opts.fileLinks ? { fileLinks: opts.fileLinks } : {}
|
|
19670
|
+
...opts.fileLinks ? { fileLinks: opts.fileLinks } : {},
|
|
19671
|
+
...opts.keepTimestamps ? { keepTimestamps: true } : {}
|
|
19108
19672
|
});
|
|
19109
19673
|
io.log(`${execute ? "\nmigrate data" : "\nmigrate data (DRY-RUN \u2014 zero writes)"}${since ? ` \u2014 incremental since ${since}${opts.prune ? " + prune" : ""}` : ""}:`);
|
|
19110
19674
|
for (const r of results) io.log(formatLoadResult(r));
|
|
@@ -21046,6 +21610,9 @@ function quoteIdent2(name) {
|
|
|
21046
21610
|
if (!IDENT_RE2.test(name)) throw new Error(`unsafe identifier '${name}' \u2014 only [A-Za-z_][A-Za-z0-9_]* is readable`);
|
|
21047
21611
|
return `"${name}"`;
|
|
21048
21612
|
}
|
|
21613
|
+
function parseTimestampNoTz(text) {
|
|
21614
|
+
return /* @__PURE__ */ new Date(`${text.replace(" ", "T")}Z`);
|
|
21615
|
+
}
|
|
21049
21616
|
function redactSourceUrl(url) {
|
|
21050
21617
|
try {
|
|
21051
21618
|
const u = new URL(url);
|
|
@@ -21298,6 +21865,10 @@ var GenericPgAdapter = class {
|
|
|
21298
21865
|
// transaction-pooler (pgbouncer/supavisor) compatible
|
|
21299
21866
|
onnotice: () => {
|
|
21300
21867
|
},
|
|
21868
|
+
// timestamp-without-tz read as UTC (the session only ever reads).
|
|
21869
|
+
types: {
|
|
21870
|
+
timestampNoTz: { to: 1114, from: [1114], parse: parseTimestampNoTz, serialize: (d) => d.toISOString() }
|
|
21871
|
+
},
|
|
21301
21872
|
connection: {
|
|
21302
21873
|
application_name: "vxil-migrate",
|
|
21303
21874
|
// startup-parameter copies of the session SETs — they survive driver
|
|
@@ -21614,6 +22185,25 @@ var supabaseSql = {
|
|
|
21614
22185
|
params: after !== null ? [after] : []
|
|
21615
22186
|
};
|
|
21616
22187
|
},
|
|
22188
|
+
/** auth.identities column NAMES (GoTrue added `provider_id` in 2023; older
|
|
22189
|
+
* projects kept the provider subject in `id`). */
|
|
22190
|
+
identitiesColumns: () => ({
|
|
22191
|
+
text: `
|
|
22192
|
+
SELECT a.attname AS column_name
|
|
22193
|
+
FROM pg_attribute a
|
|
22194
|
+
JOIN pg_class c ON c.oid = a.attrelid
|
|
22195
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
22196
|
+
WHERE n.nspname = 'auth' AND c.relname = 'identities'
|
|
22197
|
+
AND a.attnum > 0 AND NOT a.attisdropped`,
|
|
22198
|
+
params: []
|
|
22199
|
+
}),
|
|
22200
|
+
/** MIGRATE-OAUTH-IDENTITIES: the social links of ONE credentials page —
|
|
22201
|
+
* provider + the provider's subject only (never identity_data, never a
|
|
22202
|
+
* token), the four providers vxil links by subject. */
|
|
22203
|
+
identitiesFor: (userIds, subjectColumn) => ({
|
|
22204
|
+
text: `SELECT user_id::text AS user_id, provider, ${subjectColumn}::text AS subject FROM auth.identities WHERE user_id = ANY($1::uuid[]) AND provider IN ('google', 'apple', 'github', 'facebook') ORDER BY user_id, provider, ${subjectColumn}`,
|
|
22205
|
+
params: [userIds]
|
|
22206
|
+
}),
|
|
21617
22207
|
/** does this database carry the Storage schema? */
|
|
21618
22208
|
storageProbe: () => ({
|
|
21619
22209
|
text: `SELECT to_regclass('storage.buckets') IS NOT NULL AS present`,
|
|
@@ -21706,17 +22296,36 @@ var SupabaseAdapter = class extends GenericPgAdapter {
|
|
|
21706
22296
|
}
|
|
21707
22297
|
/** Credential pages (supabaseSql.credentialsPage). The rows hold bcrypt
|
|
21708
22298
|
* hashes: never logged, never written anywhere but the import request. */
|
|
21709
|
-
async *readCredentials(cursor) {
|
|
22299
|
+
async *readCredentials(cursor, opts = {}) {
|
|
21710
22300
|
const source = await this.introspect();
|
|
21711
22301
|
if (!source.authUsers?.present) throw new Error("auth.users not present on this source");
|
|
21712
22302
|
const cols = source.authUsers.columns ?? [];
|
|
21713
22303
|
if (!cols.includes("encrypted_password")) throw new Error("auth.users has no encrypted_password column \u2014 nothing to import");
|
|
22304
|
+
let subjectColumn = null;
|
|
22305
|
+
if (opts.identities) {
|
|
22306
|
+
const icols = (await this.q(supabaseSql.identitiesColumns())).map((r) => r.column_name);
|
|
22307
|
+
if (!icols.includes("user_id") || !icols.includes("provider")) {
|
|
22308
|
+
throw new Error("auth.identities not found on this source \u2014 drop --with-oauth-identities");
|
|
22309
|
+
}
|
|
22310
|
+
subjectColumn = icols.includes("provider_id") ? "provider_id" : "id";
|
|
22311
|
+
}
|
|
21714
22312
|
let after = cursor !== void 0 && cursor !== "" ? cursor : null;
|
|
21715
22313
|
for (; ; ) {
|
|
21716
22314
|
const rows = await this.q(supabaseSql.credentialsPage(after, this.batchSize, cols.includes("deleted_at")));
|
|
22315
|
+
const links = /* @__PURE__ */ new Map();
|
|
22316
|
+
if (subjectColumn !== null && rows.length > 0) {
|
|
22317
|
+
for (const r of await this.q(supabaseSql.identitiesFor(rows.map((x) => String(x.id)), subjectColumn))) {
|
|
22318
|
+
if (typeof r.subject !== "string" || r.subject === "") continue;
|
|
22319
|
+
const list = links.get(String(r.user_id)) ?? [];
|
|
22320
|
+
list.push({ provider: String(r.provider), subject: r.subject });
|
|
22321
|
+
links.set(String(r.user_id), list);
|
|
22322
|
+
}
|
|
22323
|
+
}
|
|
21717
22324
|
const out = rows.map((r) => {
|
|
21718
22325
|
const h = typeof r.encrypted_password === "string" && r.encrypted_password.length > 0 ? r.encrypted_password : null;
|
|
21719
|
-
|
|
22326
|
+
const row2 = { id: String(r.id), password_hash: h, email_verified: r.email_verified === true };
|
|
22327
|
+
if (subjectColumn !== null) row2.identities = links.get(String(r.id)) ?? [];
|
|
22328
|
+
return row2;
|
|
21720
22329
|
});
|
|
21721
22330
|
const next = rows.length === this.batchSize ? String(rows[rows.length - 1].id) : null;
|
|
21722
22331
|
yield { rows: out, nextCursor: next };
|
|
@@ -22428,6 +23037,11 @@ async function runApply(apply, sel = "prod", opts = {}) {
|
|
|
22428
23037
|
report.errors++;
|
|
22429
23038
|
}
|
|
22430
23039
|
};
|
|
23040
|
+
{
|
|
23041
|
+
const drain = cfg.features?.functions?.logDrain;
|
|
23042
|
+
const drainErr = drain === void 0 ? null : logDrainShapeError(drain);
|
|
23043
|
+
if (drainErr) throw new Error(`features.functions.logDrain: ${drainErr} \u2014 nothing was ${apply ? "pushed" : "planned"}`);
|
|
23044
|
+
}
|
|
22431
23045
|
let cmsApi = api;
|
|
22432
23046
|
if (apply) {
|
|
22433
23047
|
const enabledAt = await enableCmsBeforeSchema({ api, cfg });
|
|
@@ -22459,7 +23073,6 @@ cms: enabled (v${enabledAt}) before its schema \u2014 the declared cms config fo
|
|
|
22459
23073
|
compared.collections += Object.keys(cfg.cms.collections).length;
|
|
22460
23074
|
});
|
|
22461
23075
|
const { functions: _fnFeature, ...configFeatures } = cfg.features;
|
|
22462
|
-
void _fnFeature;
|
|
22463
23076
|
const serverApiState = {};
|
|
22464
23077
|
await runSection("config", async () => {
|
|
22465
23078
|
if (!Object.keys(configFeatures).length) return;
|
|
@@ -22476,6 +23089,8 @@ ${r.feature}:`);
|
|
|
22476
23089
|
}
|
|
22477
23090
|
console.log(`
|
|
22478
23091
|
${r.feature} (remote v${r.version}):`);
|
|
23092
|
+
const activeOrgWarn = activeOrgEnableWarning(r.feature, r.changes);
|
|
23093
|
+
if (activeOrgWarn) console.error(`vxil: warning \u2014 ${activeOrgWarn}`);
|
|
22479
23094
|
const { kept, ignored } = partitionConfigChanges(r.feature, r.changes, ignore);
|
|
22480
23095
|
console.log(formatDiff(kept));
|
|
22481
23096
|
if (ignored.length) console.log(` (${ignored.length} ignored: ${ignored.map((c) => c.path).join(", ")})`);
|
|
@@ -22488,6 +23103,30 @@ ${r.feature} (remote v${r.version}):`);
|
|
|
22488
23103
|
compared.features++;
|
|
22489
23104
|
}
|
|
22490
23105
|
});
|
|
23106
|
+
await runSection("log-drain", async () => {
|
|
23107
|
+
const r = await planOrPushLogDrain({ api, fnFeature: _fnFeature, apply });
|
|
23108
|
+
if (!r) return;
|
|
23109
|
+
if (r.remoteUnavailable) {
|
|
23110
|
+
console.log("\nfunctions.logDrain:");
|
|
23111
|
+
notCompared(r.remoteUnavailable, "needs features:read or admin");
|
|
23112
|
+
return;
|
|
23113
|
+
}
|
|
23114
|
+
const show = (d) => {
|
|
23115
|
+
const url = d?.url;
|
|
23116
|
+
if (typeof url !== "string") return "(none)";
|
|
23117
|
+
try {
|
|
23118
|
+
return new URL(url).host;
|
|
23119
|
+
} catch {
|
|
23120
|
+
return "(invalid url)";
|
|
23121
|
+
}
|
|
23122
|
+
};
|
|
23123
|
+
console.log(`
|
|
23124
|
+
functions.logDrain (remote v${r.version}):`);
|
|
23125
|
+
console.log(r.changed ? ` ~ logDrain: ${show(r.from)} \u2192 ${show(r.to)}` : " (no changes)");
|
|
23126
|
+
if (r.applied) console.log(` \u2713 applied as v${r.applied}`);
|
|
23127
|
+
if (r.changed) count("config:functions.logDrain");
|
|
23128
|
+
compared.features++;
|
|
23129
|
+
});
|
|
22491
23130
|
const strictApiState = hasFlag("strict-api-state");
|
|
22492
23131
|
const declaredApiStateKeys = [];
|
|
22493
23132
|
await runSection("api-state", async () => {
|
|
@@ -22536,7 +23175,8 @@ ${r.feature}.${r.datum} (declared in config):`);
|
|
|
22536
23175
|
if (!apply) count(key);
|
|
22537
23176
|
}
|
|
22538
23177
|
const n = r.summary.created.length + r.summary.updated.length + r.summary.recreated.length + r.summary.unchanged.length + r.summary.undeclared.length + r.summary.deleted.length + r.summary.pending_destructive.length + r.summary.ambiguous.length;
|
|
22539
|
-
|
|
23178
|
+
const ck = r.datum === "collections" ? "searchCollections" : r.datum;
|
|
23179
|
+
compared[ck] = (compared[ck] ?? 0) + n;
|
|
22540
23180
|
}
|
|
22541
23181
|
});
|
|
22542
23182
|
await runSection("functions", async () => {
|
|
@@ -24350,6 +24990,16 @@ ${plan.rationale}`);
|
|
|
24350
24990
|
if (pulled.note) pullNotes.push(pulled.note);
|
|
24351
24991
|
}
|
|
24352
24992
|
}
|
|
24993
|
+
if (features.includes("vector-search")) {
|
|
24994
|
+
const r = await api("GET", "/v1/search/collections");
|
|
24995
|
+
if (r.status === 200 && featBlocks["vector-search"]) {
|
|
24996
|
+
const block = featBlocks["vector-search"];
|
|
24997
|
+
const pulled = pulledSearchCollections(r.body.data?.collections ?? []);
|
|
24998
|
+
if (pulled.collections) block.collections = pulled.collections;
|
|
24999
|
+
else delete block.collections;
|
|
25000
|
+
if (pulled.note) pullNotes.push(pulled.note);
|
|
25001
|
+
}
|
|
25002
|
+
}
|
|
24353
25003
|
if (features.includes("ai")) {
|
|
24354
25004
|
const block = featBlocks.ai;
|
|
24355
25005
|
delete block.templates;
|
|
@@ -24901,7 +25551,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
|
|
|
24901
25551
|
architect "<describe your app>" \u2014 plain English in, a reviewed vxil.config.ts draft out
|
|
24902
25552
|
plan [--explain] \xB7 push [--dev|--target <name>|--prod] [--server [--resume <apply_id>]] [--no-gen] [--skip-functions] \xB7 pull \xB7 gen [--check] [--offline] [--out <f>] [--no-mcp] [--mcp-out <f>]
|
|
24903
25553
|
push production overrides: --allow-mock-in-prod \xB7 --allow-sandbox-in-prod \xB7 --allow-dev-origins-in-prod \xB7 --yes
|
|
24904
|
-
diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--strict-api-state] [--api-state] \u2014 CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error (an unreadable remote is 2; secrets by name only, unstored refs informational unless --strict-secrets; declared policies/subscriptions/templates/roles compared to live, an undeclared live row informational unless --strict-api-state)
|
|
25554
|
+
diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--strict-api-state] [--api-state] \u2014 CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error (an unreadable remote is 2; secrets by name only, unstored refs informational unless --strict-secrets; declared policies/subscriptions/templates/roles/search collections compared to live, an undeclared live row informational unless --strict-api-state)
|
|
24905
25555
|
doctor [--dev|--target <name>] \u2014 + secrets referenced-vs-stored (advisory) + per-function served/published/local hash + keys with last-used (stored session)
|
|
24906
25556
|
versions <feature> \xB7 rollback <feature> --to <v>
|
|
24907
25557
|
config draft show|save [--file <f>]|publish|discard <feature> \xB7 config draft duplicate <feature> --from <v> \u2014 stage a config change without making it live
|
|
@@ -25773,6 +26423,9 @@ function migrateUsage() {
|
|
|
25773
26423
|
rehashed at each user's first sign-in) \xB7 [--oidc-issuer https://<ref>.supabase.co/auth/v1]
|
|
25774
26424
|
link users for the installed-app session hand-over \xB7 [--file-links shared-link] stored
|
|
25775
26425
|
public file URLs become stable shared links instead of object ids
|
|
26426
|
+
\xB7 [--with-oauth-identities] carry the Google/Apple/GitHub/Facebook sign-in links (needs
|
|
26427
|
+
--with-password-hashes in the same run) \xB7 [--keep-timestamps] keep each row's source
|
|
26428
|
+
created_at / updated_at (cms creates; server key only)
|
|
25776
26429
|
verify reconcile source vs target counts + re-links + restate the residuals (exit 1 on deltas)
|
|
25777
26430
|
payments --from-provider stripe|paddle|paypal|revenuecat (--customers <file.csv> | --all) [--dry-run]
|
|
25778
26431
|
backfill SUBSCRIPTIONS through the server sync leg (the project's own provider keys; resumable via
|
|
@@ -25877,6 +26530,8 @@ ${migrateUsage()}`);
|
|
|
25877
26530
|
resume: hasFlag("resume"),
|
|
25878
26531
|
...hasFlag("with-password-hashes") ? { withPasswordHashes: true } : {},
|
|
25879
26532
|
...oidcIssuer !== void 0 ? { oidcIssuer } : {},
|
|
26533
|
+
...hasFlag("with-oauth-identities") ? { withOauthIdentities: true } : {},
|
|
26534
|
+
...hasFlag("keep-timestamps") ? { keepTimestamps: true } : {},
|
|
25880
26535
|
...fileLinks !== void 0 ? { fileLinks } : {},
|
|
25881
26536
|
...only?.length ? { only } : {},
|
|
25882
26537
|
...hasFlag("assume-schema") ? { assumeSchema: true } : {},
|