@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-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) lines.push(` \xB7 ${k} \u2014 live but not declared (left in place; --allow-destructive deletes it)`);
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
- // require `captcha_token` (403 captcha_failed otherwise).
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
- maxOrgsPerTenant: Type.Integer({ default: 100, minimum: 1, maximum: 1e4 }),
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.17.0";
5173
- var VXIL_SDK_PKG_VERSION = "0.24.1";
5174
- var VXIL_CLI_PKG_VERSION = "0.24.2";
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(configFeatures).length ? { features: configFeatures } : {},
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: "Mint a short-lived presigned GET URL for an available object.",
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: { object_id: { type: "string" } },
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 user's ACTIVE-ORG snapshot \u2014 the { org_id, role, perms[] } auth embeds as the session-JWT `org` claim when the tenant enables auth config orgClaims. Active org = the most recent membership (by joined_at); perms = the role's effective permission set (custom catalog entry wins, capped at 32). 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.",
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: { user_id: { type: "string", description: "The end_user_id to resolve." } },
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: "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.",
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 of equality constraints over chunk metadata. 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.",
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 equality filter, e.g. {"user_id":"u_42"}.' },
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: "Metadata equality filter applied to retrieval." },
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: "Metadata equality filter applied to retrieval." },
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 not preserved)`);
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({ deps, plan, mapping, adapter, state, save, pkColumn, itemStatus = "published", delta }) {
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 res = await deps.api("POST", `/v1/cms/items/${enc2}`, { data: item.create, status: itemStatus });
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
- for await (const batch of adapter.readCredentials(cursors[KEY] ?? void 0)) {
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
- const credentials = opts.withPasswordHashes || opts.oidcIssuer !== void 0 ? { withHashes: opts.withPasswordHashes === true, ...opts.oidcIssuer !== void 0 ? { oidcIssuer: opts.oidcIssuer } : {} } : void 0;
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
- return { id: String(r.id), password_hash: h, email_verified: r.email_verified === true };
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
- compared[r.datum] = (compared[r.datum] ?? 0) + n;
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 } : {},