@vxil/cli 0.24.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.0";
5174
- var VXIL_CLI_PKG_VERSION = "0.24.1";
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";
@@ -7951,18 +8281,18 @@ var PUBLIC_KEY_DENY_SCOPES = /* @__PURE__ */ new Set([
7951
8281
  var TENANT_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
7952
8282
  var FUNCTION_SOURCE_MAX_BYTES = 512e3;
7953
8283
  var FN_PLATFORM_DEFAULT_CPU_MS = 3e4;
7954
- function functionScriptCpuMs(declaredCpuMs, ceilingMs) {
8284
+ function functionScriptCpuMs(declaredCpuMs, ceilingMs, defaultMs = ceilingMs) {
7955
8285
  const d = typeof declaredCpuMs === "number" && Number.isFinite(declaredCpuMs) && declaredCpuMs >= 1 ? Math.floor(declaredCpuMs) : null;
7956
- return d === null ? ceilingMs : Math.min(d, ceilingMs);
8286
+ return Math.min(d ?? defaultMs, ceilingMs);
7957
8287
  }
7958
8288
  function recordedScriptCpuMs(raw) {
7959
8289
  const c = raw?.cpuMs;
7960
8290
  return isPositiveInt(c) ? c : FN_PLATFORM_DEFAULT_CPU_MS;
7961
8291
  }
7962
- function planScriptCpuMs(runtime, declaredCpuMs, ceilingMs) {
8292
+ function planScriptCpuMs(runtime, declaredCpuMs, ceilingMs, defaultMs = ceilingMs) {
7963
8293
  const recorded = runtime?.cpuMs;
7964
8294
  if (!isPositiveInt(recorded) && ceilingMs >= FN_PLATFORM_DEFAULT_CPU_MS) return FN_PLATFORM_DEFAULT_CPU_MS;
7965
- return functionScriptCpuMs(declaredCpuMs, ceilingMs);
8295
+ return functionScriptCpuMs(declaredCpuMs, ceilingMs, defaultMs);
7966
8296
  }
7967
8297
  function isPositiveInt(v) {
7968
8298
  return typeof v === "number" && Number.isInteger(v) && v > 0;
@@ -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,
@@ -8823,7 +9193,9 @@ async function readRemote(api) {
8823
9193
  const deployRuntime = wire ? recordedFunctionsRuntime(wire) : FUNCTIONS_RUNTIME_LEGACY;
8824
9194
  const ceilingWire = res.status === 200 ? res.body.data?.cpuMsCeiling : void 0;
8825
9195
  const cpuMsCeiling = typeof ceilingWire === "number" && Number.isInteger(ceilingWire) && ceilingWire > 0 ? ceilingWire : null;
8826
- if (res.status !== 200) return { map: map2, unavailable, deployRuntime, cpuMsCeiling };
9196
+ const defaultWire = res.status === 200 ? res.body.data?.cpuMsDefault : void 0;
9197
+ const cpuMsDefault = typeof defaultWire === "number" && Number.isInteger(defaultWire) && defaultWire > 0 ? defaultWire : null;
9198
+ if (res.status !== 200) return { map: map2, unavailable, deployRuntime, cpuMsCeiling, cpuMsDefault };
8827
9199
  const fns = Array.isArray(res.body.data?.functions) ? res.body.data.functions : [];
8828
9200
  for (const f of fns) {
8829
9201
  map2.set(f.name, {
@@ -8838,7 +9210,7 @@ async function readRemote(api) {
8838
9210
  runtime: recordedFunctionsRuntime(f.runtime)
8839
9211
  });
8840
9212
  }
8841
- return { map: map2, unavailable: null, deployRuntime, cpuMsCeiling };
9213
+ return { map: map2, unavailable: null, deployRuntime, cpuMsCeiling, cpuMsDefault };
8842
9214
  }
8843
9215
  function sameSecretSet(a, b2) {
8844
9216
  return a.length === b2.length && a.every((x) => b2.includes(x));
@@ -8877,7 +9249,7 @@ async function planFunctions({ api, functions, cwd, apply, onNote, remoteOnly =
8877
9249
  const sizeWarnings = headroom ? [{ fn: name, message: headroom }] : [];
8878
9250
  const retryN = bindings.find((b2) => b2.retry)?.retry?.maxAttempts;
8879
9251
  const label = `${name} (${Math.round(bytes / 100) / 10} KB) [${bindings.map((b2) => b2.kind).join("+")}${retryN !== void 0 ? " \xB7 retry:" + retryN : ""}${def.scopes?.length ? " \xB7 " + def.scopes.join(",") : ""}${secrets.length ? " \xB7 secrets:" + secrets.map((s) => s.slice("secret:".length)).join(",") : ""}${limits?.cpuMs !== void 0 ? " \xB7 cpu:" + limits.cpuMs + "ms" : ""}${limits?.timeoutMs !== void 0 ? " \xB7 egress:" + limits.timeoutMs + "ms" : ""}]`;
8880
- if (remoteFn !== void 0 && remoteFn.scriptRef.endsWith(`-${sha}`) && sameSecretSet(remoteFn.secrets, secrets) && sameFunctionsRuntime(remoteFn.runtime, read.deployRuntime) && (read.cpuMsCeiling === null || recordedScriptCpuMs(remoteFn.runtime) === planScriptCpuMs(remoteFn.runtime, limits?.cpuMs, read.cpuMsCeiling)) && sameStringSet2(clampFunctionScopes(def.scopes), remoteFn.scopes) && sameStringSet2(def.egressAllow ?? [], remoteFn.egressAllow) && stableStringify(remoteFn.bindings) === stableStringify(bindings) && stableStringify(remoteFn.signature) === stableStringify(def.signature ?? null) && stableStringify(remoteFn.limits ?? null) === stableStringify(limits ?? null) && stableStringify(remoteFn.vars) === stableStringify(def.vars ?? {}) && !force) {
9252
+ if (remoteFn !== void 0 && remoteFn.scriptRef.endsWith(`-${sha}`) && sameSecretSet(remoteFn.secrets, secrets) && sameFunctionsRuntime(remoteFn.runtime, read.deployRuntime) && (read.cpuMsCeiling === null || recordedScriptCpuMs(remoteFn.runtime) === planScriptCpuMs(remoteFn.runtime, limits?.cpuMs, read.cpuMsCeiling, read.cpuMsDefault ?? read.cpuMsCeiling)) && sameStringSet2(clampFunctionScopes(def.scopes), remoteFn.scopes) && sameStringSet2(def.egressAllow ?? [], remoteFn.egressAllow) && stableStringify(remoteFn.bindings) === stableStringify(bindings) && stableStringify(remoteFn.signature) === stableStringify(def.signature ?? null) && stableStringify(remoteFn.limits ?? null) === stableStringify(limits ?? null) && stableStringify(remoteFn.vars) === stableStringify(def.vars ?? {}) && !force) {
8881
9253
  return { change: { name, kind: "unchanged", detail: label }, applied: 0, cmsHooks: null, webhooks: null, warnings: sizeWarnings, hook: null };
8882
9254
  }
8883
9255
  const bindingChanges = remoteFn !== void 0 ? diffBindings(remoteFn.bindings, bindings) : null;
@@ -9053,6 +9425,12 @@ function writeRolesWidens(declared, remote) {
9053
9425
  if (declared.length === 0) return true;
9054
9426
  return declared.some((r) => !remote.includes(r));
9055
9427
  }
9428
+ function orgFieldWidens(declared, remote) {
9429
+ return remote !== null && declared !== remote;
9430
+ }
9431
+ function orgReadRolesWidens(declared, remote) {
9432
+ return writeRolesWidens(declared, remote);
9433
+ }
9056
9434
  function endUserAccessOf(v) {
9057
9435
  return v === "read" || v === "none" ? v : "readwrite";
9058
9436
  }
@@ -9149,6 +9527,9 @@ function remoteCollectionOf(c) {
9149
9527
  public: c.public === true,
9150
9528
  endUserAccess: endUserAccessOf(c.end_user_access),
9151
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,
9152
9533
  actions: normalizeActions(c.actions),
9153
9534
  fields: Array.isArray(c.fields) ? c.fields : []
9154
9535
  };
@@ -9163,6 +9544,9 @@ function pulledCollectionBlock(c) {
9163
9544
  ...rc.public ? { public: true } : {},
9164
9545
  ...rc.endUserAccess !== "readwrite" ? { endUserAccess: rc.endUserAccess } : {},
9165
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 } : {},
9166
9550
  ...rc.actions.length ? { actions: rc.actions } : {},
9167
9551
  fields
9168
9552
  };
@@ -9227,6 +9611,12 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
9227
9611
  if (wr !== void 0 && (!Array.isArray(wr) || wr.some((r) => typeof r !== "string"))) {
9228
9612
  throw new Error(`cms.collections.${name}.writeRoles must be an array of role slugs (got ${JSON.stringify(wr)})`);
9229
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
+ }
9230
9620
  }
9231
9621
  const read = await readRemote2(api);
9232
9622
  if (read.unavailable) {
@@ -9407,6 +9797,63 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
9407
9797
  });
9408
9798
  }
9409
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
+ }
9410
9857
  const declaredActions = normalizeActions(def.actions);
9411
9858
  if (stableStringify(declaredActions) !== stableStringify(rc.actions)) {
9412
9859
  collChanges.push({
@@ -9482,6 +9929,10 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
9482
9929
  if (access !== "readwrite") body.end_user_access = access;
9483
9930
  const roles = writeRolesOf(def.writeRoles);
9484
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;
9485
9936
  if (def.actions && def.actions.length > 0) body.actions = normalizeActions(def.actions);
9486
9937
  const res = await api("POST", "/v1/cms/collections", body);
9487
9938
  if (res.status !== 201 && res.status !== 200 && res.body.error?.code !== "collection_exists") {
@@ -9541,6 +9992,28 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
9541
9992
  throw new Error(`cms: set writeRoles on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
9542
9993
  }
9543
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++;
9544
10017
  } else if (c.kind === "set-actions") {
9545
10018
  const target = normalizeActions(collections[c.collection].actions);
9546
10019
  const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { actions: target });
@@ -9606,7 +10079,7 @@ function formatCmsChanges(changes) {
9606
10079
  if (c.kind === "destructive") return ` ! ${c.detail}`;
9607
10080
  if (c.kind === "warn") return ` \u26A0 ${c.detail}`;
9608
10081
  if (c.kind === "reindex") return ` \u21BB ${c.detail}`;
9609
- 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}`;
9610
10083
  return ` + ${c.detail}`;
9611
10084
  }).join("\n");
9612
10085
  }
@@ -12501,7 +12974,11 @@ var TOOLS = [
12501
12974
  description: "Explicit locale wins; otherwise the recipient's stored `locale` attribute (users_upsert attributes.locale), then the tenant's config.defaultLocale."
12502
12975
  },
12503
12976
  send_at: { type: "string", description: "future ISO instant, \u2264 30 d out (mutually exclusive with delay_seconds)" },
12504
- 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." }
12505
12982
  },
12506
12983
  required: ["user_id", "template", "data"]
12507
12984
  },
@@ -12528,6 +13005,10 @@ var TOOLS = [
12528
13005
  channel: { type: "string", enum: ["email", "inbox", "both"] },
12529
13006
  send_at: { type: "string", description: "future ISO instant (\u2264 30 d)" },
12530
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" },
12531
13012
  idempotency_key: { type: "string" }
12532
13013
  },
12533
13014
  required: ["user_id", "template", "data"]
@@ -13210,14 +13691,19 @@ var TOOLS = [
13210
13691
  {
13211
13692
  name: "files_get_download_url",
13212
13693
  feature: "files",
13213
- 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.',
13214
13695
  inputSchema: {
13215
13696
  type: "object",
13216
- 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
+ },
13217
13702
  required: ["object_id"]
13218
13703
  },
13219
13704
  method: "GET",
13220
- 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"]
13221
13707
  },
13222
13708
  {
13223
13709
  name: "files_get_download_urls",
@@ -13353,7 +13839,7 @@ var TOOLS = [
13353
13839
  {
13354
13840
  name: "cms_list_collections",
13355
13841
  feature: "cms",
13356
- 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.",
13357
13843
  inputSchema: { type: "object", properties: {} },
13358
13844
  method: "GET",
13359
13845
  path: "/v1/cms/collections"
@@ -13701,15 +14187,18 @@ var TOOLS = [
13701
14187
  {
13702
14188
  name: "orgs_session_claims",
13703
14189
  feature: "orgs",
13704
- 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.",
13705
14191
  inputSchema: {
13706
14192
  type: "object",
13707
- 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
+ },
13708
14197
  required: ["user_id"]
13709
14198
  },
13710
14199
  method: "GET",
13711
14200
  path: "/v1/orgs/session-claims",
13712
- queryArgs: ["user_id"]
14201
+ queryArgs: ["user_id", "org_id"]
13713
14202
  },
13714
14203
  {
13715
14204
  name: "orgs_define_role",
@@ -13868,7 +14357,7 @@ var TOOLS = [
13868
14357
  {
13869
14358
  name: "functions_list",
13870
14359
  feature: "functions",
13871
- description: "The project's deployed functions: name, endpoint (/v1/fn/<name>), trigger bindings, scopes, limits and the runtime each runs under, plus `enabled` (false while the project's functions are paused as a whole \u2014 invokes then answer 404) and `cpuMsCeiling`. Each entry carries its plain `vars` only for a server key holding features:read or features:write (never for an end-user request). Read-only; any authenticated key may read it. Use it before functions_invoke or functions_logs to get the exact names.",
14360
+ description: "The project's deployed functions: name, endpoint (/v1/fn/<name>), trigger bindings, scopes, limits and the runtime each runs under, plus `enabled` (false while the project's functions are paused as a whole \u2014 invokes then answer 404), `cpuMsCeiling` and `cpuMsDefault` (the CPU a function declaring no limits.cpuMs gets). Each entry carries its plain `vars` only for a server key holding features:read or features:write (never for an end-user request). Read-only; any authenticated key may read it. Use it before functions_invoke or functions_logs to get the exact names.",
13872
14361
  inputSchema: { type: "object", properties: {} },
13873
14362
  method: "GET",
13874
14363
  path: "/v1/functions"
@@ -13892,7 +14381,7 @@ var TOOLS = [
13892
14381
  {
13893
14382
  name: "functions_logs",
13894
14383
  feature: "functions",
13895
- 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.',
13896
14385
  inputSchema: {
13897
14386
  type: "object",
13898
14387
  properties: {
@@ -14063,7 +14552,7 @@ var TOOLS = [
14063
14552
  {
14064
14553
  name: "search_query",
14065
14554
  feature: "vector-search",
14066
- 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.",
14067
14556
  inputSchema: {
14068
14557
  type: "object",
14069
14558
  properties: {
@@ -14071,7 +14560,7 @@ var TOOLS = [
14071
14560
  query: { type: "string", description: "Natural-language query text." },
14072
14561
  top_k: { type: "number", minimum: 1, maximum: 100, default: 10 },
14073
14562
  mode: { type: "string", enum: ["hybrid", "vector", "keyword"], default: "hybrid" },
14074
- 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.' },
14075
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." }
14076
14565
  },
14077
14566
  required: ["collection", "query"]
@@ -14237,7 +14726,7 @@ var TOOLS = [
14237
14726
  template: { type: "string", description: "Override the answer template (defaults to rag config defaultTemplate)." },
14238
14727
  top_k: { type: "number", minimum: 1, maximum: 100, description: "Chunks to retrieve before budgeting." },
14239
14728
  mode: { type: "string", enum: ["hybrid", "vector", "keyword"] },
14240
- 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.' },
14241
14730
  provider: { type: "string" },
14242
14731
  model: { type: "string" },
14243
14732
  input: { type: "object", additionalProperties: true, description: "Extra template vars beyond {{query}}/{{context}}." },
@@ -14260,7 +14749,7 @@ var TOOLS = [
14260
14749
  collection: { type: "string", description: "Collection to search (defaults to rag config defaultCollection)." },
14261
14750
  top_k: { type: "number", minimum: 1, maximum: 100, description: "Chunks to retrieve." },
14262
14751
  mode: { type: "string", enum: ["hybrid", "vector", "keyword"] },
14263
- 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.' },
14264
14753
  rerank: { type: "boolean", description: "Forward rerank:true to vector-search's BYO rerank pass (inert until configured there)." },
14265
14754
  boosts: { type: "object", additionalProperties: true, description: "Per-metadata-field boost specs: { field: { kind:'recency', halfLifeDays?, weight? } | { kind:'value', weights:{\u2026} } }." },
14266
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." },
@@ -14846,6 +15335,10 @@ function collectSecretRefs(cfg) {
14846
15335
  if (m) add({ name: m[1], feature, via: `features.${feature}.${path}` });
14847
15336
  }
14848
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
+ }
14849
15342
  for (const [fn, def] of Object.entries(cfg.functions ?? {})) {
14850
15343
  for (const ref of normalizeSecretRefs(def.secrets)) {
14851
15344
  const bare = ref.slice("secret:".length);
@@ -14921,7 +15414,8 @@ var COMPARED_LABELS = [
14921
15414
  ["subscriptions", "subscription"],
14922
15415
  ["campaigns", "campaign"],
14923
15416
  ["templates", "template"],
14924
- ["roles", "role"]
15417
+ ["roles", "role"],
15418
+ ["searchCollections", "search collection"]
14925
15419
  ];
14926
15420
  function formatCompared(c) {
14927
15421
  const parts = [];
@@ -15879,8 +16373,12 @@ function renderTryReport(result, opts) {
15879
16373
 
15880
16374
  // src/brief.ts
15881
16375
  var SITE = "https://vxil.com";
15882
- function briefDocsUrl(docs) {
15883
- return docs.startsWith("/") ? `${SITE}${docs}` : docs;
16376
+ var STAGING_SITE = "https://staging.vxil.org";
16377
+ function briefSiteFor(edgeBase) {
16378
+ return envLabelFor(edgeBase) === "production" ? SITE : STAGING_SITE;
16379
+ }
16380
+ function briefDocsUrl(docs, site = SITE) {
16381
+ return docs.startsWith("/") ? `${site}${docs}` : docs;
15884
16382
  }
15885
16383
  function renderBriefLines(brief, opts = {}) {
15886
16384
  if (!brief) {
@@ -15903,7 +16401,7 @@ function renderBriefLines(brief, opts = {}) {
15903
16401
  }
15904
16402
  return out;
15905
16403
  }
15906
- function renderBriefMarkdown(brief, app = {}) {
16404
+ function renderBriefMarkdown(brief, app = {}, opts = {}) {
15907
16405
  const name = (app.name ?? "").trim() || "Your app";
15908
16406
  if (!brief) {
15909
16407
  return [
@@ -15922,7 +16420,7 @@ function renderBriefMarkdown(brief, app = {}) {
15922
16420
  for (const s of brief.sections) {
15923
16421
  lines.push(`## ${s.title}`, "");
15924
16422
  for (const i of s.items) {
15925
- lines.push(`- **[${i.title}](${briefDocsUrl(i.docs)})** \u2014 ${i.fact}`);
16423
+ lines.push(`- **[${i.title}](${briefDocsUrl(i.docs, opts.site)})** \u2014 ${i.fact}`);
15926
16424
  if (i.why) lines.push(` _why:_ ${i.why}`);
15927
16425
  if (i.replaces) lines.push(` _instead of:_ ${i.replaces}`);
15928
16426
  }
@@ -16095,7 +16593,9 @@ var EXTRA_KNOWN_FLAGS = [
16095
16593
  "include-empty",
16096
16594
  "include-password-hashes",
16097
16595
  "with-password-hashes",
16596
+ "with-oauth-identities",
16098
16597
  "keep-source-id",
16598
+ "keep-timestamps",
16099
16599
  "oidc-issuer",
16100
16600
  "only",
16101
16601
  "pg-schema",
@@ -16798,7 +17298,8 @@ function inferMapping(source, opts = {}) {
16798
17298
  if (owner) mapping.userRefColumn = owner.column;
16799
17299
  for (const col of t.columns) {
16800
17300
  if (col.name === "created_at" || col.name === "updated_at") {
16801
- 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 };
16802
17303
  continue;
16803
17304
  }
16804
17305
  const isOwnerCol = owner?.column === col.name;
@@ -17200,6 +17701,32 @@ function storagePatch(row2, fields, resolve8) {
17200
17701
  function readColumns(mapping, pk, extra = []) {
17201
17702
  return [.../* @__PURE__ */ new Set([pk, ...mapping.fields.map((f) => f.source), ...mapping.changeColumn ? [mapping.changeColumn] : [], ...extra])];
17202
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
+ }
17203
17730
  var SCALAR_UNIQUE = /* @__PURE__ */ new Set(["string", "int", "float", "datetime"]);
17204
17731
  function buildItem(row2, mapping, state, tableByCollection, resolve8) {
17205
17732
  const full = {};
@@ -17256,7 +17783,18 @@ function holdsContent(stored, item, storageTargets) {
17256
17783
  return cur === rowHash(v) || storageTargets.has(k) && cur === rowHash(item.hashInput[k]);
17257
17784
  });
17258
17785
  }
17259
- 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
+ }) {
17260
17798
  const table = mapping.table;
17261
17799
  const collection = mapping.collection;
17262
17800
  const key = `cms:${table}`;
@@ -17290,8 +17828,12 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
17290
17828
  let unhashedSame = 0;
17291
17829
  const enc2 = encodeURIComponent(collection);
17292
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
+ }
17293
17835
  const readOpts = {
17294
- columns: readColumns(mapping, pk),
17836
+ columns: readColumns(mapping, pk, [tsCols.created, tsCols.updated].filter((c) => c !== void 0)),
17295
17837
  ...delta && mapping.changeColumn ? { changedSince: { column: mapping.changeColumn, since: delta.since } } : {}
17296
17838
  };
17297
17839
  const noteStorage = (sourceId, row2) => {
@@ -17374,7 +17916,12 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
17374
17916
  continue;
17375
17917
  }
17376
17918
  if (deps.dryRun) continue;
17377
- 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
+ });
17378
17925
  if (res.status === 201 || res.status === 200) {
17379
17926
  const itemId = res.body.data?.item_id;
17380
17927
  if (typeof itemId !== "string") {
@@ -17559,11 +18106,14 @@ async function loadUsers({ deps, adapter, mapping, state, save, delta = false })
17559
18106
  // src/migrate/loaders/credentials.ts
17560
18107
  var CREDENTIALS_BATCH_MAX = 500;
17561
18108
  var KEY = "users:credentials";
18109
+ var IDENTITIES_PER_USER_MAX = 8;
17562
18110
  function credentialEntry(row2, withHashes) {
18111
+ const identities = (row2.identities ?? []).slice(0, IDENTITIES_PER_USER_MAX);
17563
18112
  return {
17564
18113
  id: row2.id,
17565
18114
  ...withHashes && row2.password_hash ? { password_hash: row2.password_hash } : {},
17566
- ...row2.email_verified ? { email_verified: true } : {}
18115
+ ...row2.email_verified ? { email_verified: true } : {},
18116
+ ...identities.length > 0 ? { identities } : {}
17567
18117
  };
17568
18118
  }
17569
18119
  async function loadCredentials2({ deps, adapter, state, save, opts, delta = false }) {
@@ -17576,11 +18126,17 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
17576
18126
  const sent = hashesFor(state, KEY);
17577
18127
  if (cursors[KEY] === null) {
17578
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
+ }
17579
18132
  return result;
17580
18133
  }
17581
18134
  const reasons = /* @__PURE__ */ new Map();
17582
18135
  let withPassword = 0;
17583
- 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)) {
17584
18140
  const entries = [];
17585
18141
  const digests = [];
17586
18142
  for (const row2 of batch.rows) {
@@ -17592,6 +18148,7 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
17592
18148
  continue;
17593
18149
  }
17594
18150
  if (entry.password_hash) withPassword++;
18151
+ if (entry.identities) withLinks++;
17595
18152
  entries.push(entry);
17596
18153
  digests.push(h);
17597
18154
  }
@@ -17609,6 +18166,7 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
17609
18166
  return result;
17610
18167
  }
17611
18168
  const data2 = res.body.data ?? {};
18169
+ linked += typeof data2.identities_linked === "number" ? data2.identities_linked : 0;
17612
18170
  const taken = (data2.results ?? []).filter((r) => r.status === "skipped" && r.reason === "id_taken").length;
17613
18171
  if (taken > 0) {
17614
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.`);
@@ -17630,10 +18188,13 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
17630
18188
  if (batch.nextCursor === null) break;
17631
18189
  }
17632
18190
  if (deps.dryRun) {
17633
- 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");
17634
18192
  } else {
17635
18193
  cursors[KEY] = null;
17636
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
+ }
17637
18198
  }
17638
18199
  if (reasons.size > 0) {
17639
18200
  const list = [...reasons].map(([r, n]) => `${r}=${n}`).join(" ");
@@ -18710,6 +19271,7 @@ async function runData(deps, plan, adapter, opts = {}) {
18710
19271
  save: g.save,
18711
19272
  ...opts.pkColumns?.[table] !== void 0 ? { pkColumn: opts.pkColumns[table] } : {},
18712
19273
  ...opts.itemStatus !== void 0 ? { itemStatus: opts.itemStatus } : {},
19274
+ ...opts.keepTimestamps ? { keepTimestamps: true } : {},
18713
19275
  ...delta ? { delta } : {}
18714
19276
  });
18715
19277
  results.push(r);
@@ -19063,7 +19625,14 @@ async function runMigrateData(io, opts = {}) {
19063
19625
  if (opts.oidcIssuer !== void 0 && !/^https:\/\/[^\s?#]+$/.test(opts.oidcIssuer)) {
19064
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)`);
19065
19627
  }
19066
- 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;
19067
19636
  const tableNames = new Set(plan.tables.map((t) => t.table));
19068
19637
  for (const o of opts.only ?? []) {
19069
19638
  if (!DATA_STEPS.has(o) && !tableNames.has(o)) {
@@ -19098,7 +19667,8 @@ async function runMigrateData(io, opts = {}) {
19098
19667
  ...opts.prune ? { prune: true } : {},
19099
19668
  ...specs ? { payments: specs } : {},
19100
19669
  ...credentials ? { credentials } : {},
19101
- ...opts.fileLinks ? { fileLinks: opts.fileLinks } : {}
19670
+ ...opts.fileLinks ? { fileLinks: opts.fileLinks } : {},
19671
+ ...opts.keepTimestamps ? { keepTimestamps: true } : {}
19102
19672
  });
19103
19673
  io.log(`${execute ? "\nmigrate data" : "\nmigrate data (DRY-RUN \u2014 zero writes)"}${since ? ` \u2014 incremental since ${since}${opts.prune ? " + prune" : ""}` : ""}:`);
19104
19674
  for (const r of results) io.log(formatLoadResult(r));
@@ -20266,7 +20836,7 @@ export default defineConfig({
20266
20836
  "functions": {
20267
20837
  "prune-receipts.ts": "// prune-receipts.ts \u2014 THE GARBAGE COLLECTOR (a vxil function, cron trigger).\n//\n// Trigger: cron `30 */6 * * *` (every 6 hours).\n//\n// A push service accepts a message and answers \"ok\" long before it knows whether\n// the device still exists. The real verdict arrives later, as a RECEIPT keyed by\n// the id the send returned. Almost nobody collects them, which is why push\n// registries only ever grow: dead tokens are re-sent to forever, quota is spent\n// on handsets that were wiped a year ago, and delivery rates look worse than\n// they are.\n//\n// This cron closes that loop:\n// 1. resume the walk of `push_tokens` where the previous tick stopped (the\n// cursor lives in `push_state`), collecting rows that carry a `receipt_id`,\n// 2. ask the push service for those receipts, in batches,\n// 3. `DeviceNotRegistered` \u2192 DELETE the row (\u2605 invariant 3),\n// any other error \u2192 count it and keep the device,\n// ok \u2192 clear the receipt id; there is nothing left to check,\n// no receipt and older than RECEIPT_MAX_AGE_H \u2192 clear it (the service no\n// longer has it; asking forever would pin the row),\n// Every clear is a PATCH with If-Match on the row version that was read:\n// if `send-push` parked a NEWER receipt id on the device meanwhile, the\n// clear is refused (409) and the newer id stays for the next walk,\n// 4. save the cursor \u2014 or '' when the walk reached the oldest row, so the\n// next tick starts again from the newest.\n//\n// Everything is bounded: one tick reads at most MAX_ROWS rows and acts on at\n// most MAX_ACTIONS of them, so its outbound calls stay in the low hundreds.\n// Because the cursor persists, a registry of ANY size is walked end to end over\n// successive ticks \u2014 MAX_ROWS \xD7 4 ticks a day. (The walk follows the default\n// newest-first order; rows registered while a walk is in progress are covered\n// by the next walk.)\n\n// cron-walk: persisted-cursor \u2014 the walk's cursor lives in `push_state` and is saved with If-Match.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\nconst RECEIPTS_URL = 'https://exp.host/--/api/v2/push/getReceipts';\n/** Receipt ids per request. */\nconst RECEIPT_BATCH = 300;\n/** Token rows examined per cron tick. */\nconst MAX_ROWS = 2000;\n/** Rows with a pending receipt acted on per tick (each costs one PATCH or DELETE). */\nconst MAX_ACTIONS = 300;\n/** A receipt id parked longer than this is cleared as unknowable. */\nconst RECEIPT_MAX_AGE_H = 48;\n/** cms page size \u2014 100 is the default `maxPageSize` for the cms feature. */\nconst PAGE = 100;\n/** This cron's row in `push_state`. */\nconst STATE_KEY = 'prune-receipts';\n\ntype Env = CronFunctionEnvelope;\ninterface TokenRow {\n item_id: string;\n version?: number;\n data: { token?: string; receipt_id?: string; receipt_at?: string; failures?: number };\n}\ninterface Receipt { status?: string; message?: string; details?: { error?: string } }\ninterface StateRow { item_id: string; version?: number; data: { cursor?: string } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n // \u2500\u2500 1. Resume the walk; collect pending receipts. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const state = await readState(base, H, STATE_KEY);\n if (state === 'error') return Response.json({ error: 'state_read_failed' }, { status: 502 });\n const startedAt = state?.data.cursor || null;\n const pending = new Map<string, TokenRow>(); // receipt_id \u2192 row\n let cursor: string | null = startedAt;\n let scanned = 0;\n let wrapped = false;\n while (scanned < MAX_ROWS && pending.size < MAX_ACTIONS) {\n // NOTE: no `sort=` here on purpose. In the DEFAULT order (newest first)\n // a cursor is a plain item id, which is what we persist between ticks\n // and resume `item_id <` from. Paging beats ordering here.\n const url = `${base}/v1/cms/items/push_tokens?limit=${PAGE}`\n + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : '');\n const res = await fetch(url, { headers: H });\n if (!res.ok) return Response.json({ error: 'list_failed', status: res.status }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: TokenRow[]; next_cursor?: string | null } };\n const items = body.data?.items ?? [];\n let stoppedEarly = false;\n for (const it of items) {\n scanned++;\n if (it.data.receipt_id) pending.set(it.data.receipt_id, it);\n cursor = it.item_id; // resume AFTER the last row actually looked at\n if (pending.size >= MAX_ACTIONS || scanned >= MAX_ROWS) { stoppedEarly = true; break; }\n }\n if (stoppedEarly) break;\n if (!body.data?.next_cursor || items.length === 0) { wrapped = true; break; }\n cursor = body.data.next_cursor;\n }\n // Reached the oldest row \u2192 the next tick starts again from the newest.\n const nextCursor = wrapped ? '' : (cursor ?? '');\n\n // \u2500\u2500 2 + 3. Ask for verdicts and act on them. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const ids = [...pending.keys()];\n let checked = 0;\n let pruned = 0;\n let cleared = 0;\n let expired = 0;\n let raced = 0;\n const errors: string[] = [];\n const now = Date.now();\n\n for (let i = 0; i < ids.length; i += RECEIPT_BATCH) {\n const slice = ids.slice(i, i + RECEIPT_BATCH);\n const res = await fetch(RECEIPTS_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify({ ids: slice }),\n }).catch(() => null);\n if (!res || !res.ok) {\n errors.push(`receipts request returned ${res?.status ?? 'network error'}`);\n continue;\n }\n // Receipts come back keyed by id \u2014 a receipt that is not ready yet is\n // simply ABSENT, which is not an error: it is checked again next walk.\n const map = (((await res.json().catch(() => ({}))) as { data?: Record<string, Receipt> }).data) ?? {};\n for (const id of slice) {\n const receipt = map[id];\n const row = pending.get(id)!;\n if (!receipt) {\n // Not ready \u2014 or no longer kept by the service. Past the age bound\n // the id can never be answered: clear it so the row is not pinned.\n const at = row.data.receipt_at ? Date.parse(row.data.receipt_at) : NaN;\n if (Number.isFinite(at) && now - at > RECEIPT_MAX_AGE_H * 3_600_000) {\n if (await patch(base, H, row, { receipt_id: '', checked_at: iso() })) expired++; else raced++;\n }\n continue;\n }\n checked++;\n if (receipt.status === 'ok') {\n // Delivered. Nothing more to check for this device.\n if (await patch(base, H, row, { receipt_id: '', failures: 0, checked_at: iso() })) cleared++; else raced++;\n continue;\n }\n const reason = receipt.details?.error ?? receipt.message ?? 'unknown';\n if (reason === 'DeviceNotRegistered') {\n // \u2605 INVARIANT 3. The device is gone \u2014 the row goes with it. This is\n // also the ONLY safe automatic delete: every other error is transient\n // or about the message, not about the device's existence.\n await fetch(`${base}/v1/cms/items/push_tokens/${row.item_id}`, { method: 'DELETE', headers: H })\n .catch(() => null);\n pruned++;\n continue;\n }\n if (!await patch(base, H, row, {\n receipt_id: '',\n failures: (row.data.failures ?? 0) + 1,\n last_error: String(reason).slice(0, 300),\n checked_at: iso(),\n })) raced++;\n errors.push(reason);\n }\n }\n\n // \u2500\u2500 4. Save where the walk stopped (If-Match: a concurrent tick wins). \u2500\u2500\u2500\u2500\n const saved = await writeState(base, H, STATE_KEY, state, nextCursor);\n\n return Response.json({\n scanned, pending: pending.size, checked, pruned, cleared, expired, raced,\n resumed_from: startedAt, cursor: nextCursor, wrapped, saved, errors: errors.slice(0, 10),\n });\n },\n};\n\n/** Clear/record against the version that was READ. false = the row changed\n * meanwhile (409: a newer receipt is parked \u2014 leave it) or the write failed. */\nasync function patch(base: string, H: Record<string, string>, row: TokenRow, data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${base}/v1/cms/items/push_tokens/${row.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(row.version !== undefined ? { 'if-match': String(row.version) } : {}) },\n body: JSON.stringify({ data }),\n }).catch(() => null);\n return !!res && res.ok;\n}\n\n/** This cron's `push_state` row, null when it does not exist yet. */\nasync function readState(base: string, H: Record<string, string>, key: string): Promise<StateRow | null | 'error'> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${base}/v1/cms/items/push_state?filter=${filter}&limit=1`, { headers: H });\n if (!res.ok) return 'error';\n return ((await res.json()) as { data?: { items?: StateRow[] } }).data?.items?.[0] ?? null;\n}\n\n/** Persist the cursor. First tick creates the row (a racing tick's 409 on the\n * unique `key` is fine \u2014 that tick saved its own); later ticks PATCH with\n * If-Match so two overlapping ticks cannot both move it. */\nasync function writeState(\n base: string, H: Record<string, string>, key: string, state: StateRow | null, cursor: string,\n): Promise<boolean> {\n const data = { key, cursor, updated_at: iso() };\n const res = state\n ? await fetch(`${base}/v1/cms/items/push_state/${state.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(state.version !== undefined ? { 'if-match': String(state.version) } : {}) },\n body: JSON.stringify({ data }),\n }).catch(() => null)\n : await fetch(`${base}/v1/cms/items/push_state`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n }).catch(() => null);\n return !!res && res.ok;\n}\n\nconst iso = () => new Date().toISOString();\n",
20268
20838
  "register-token.ts": "// register-token.ts \u2014 THE DEVICE REGISTRY (a vxil function with five bindings).\n//\n// http POST /v1/fn/register-token { token, user_id?, platform?, locale? }\n// cmsHook a new `push_token_claims` row (the app's async re-own request)\n// webhook `auth.user.erased` / `user.erased` / `user.deleted` (forget the user)\n//\n// One bundle, because all three are the same job: keeping \"which user holds\n// which device\" true. The envelope's `trigger` says which door was used.\n//\n// \u2500\u2500 http: register \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Call it every time your app starts and every time the OS hands you a new push\n// token. It is idempotent, and it enforces the two registry invariants:\n//\n// 1. ONE TOKEN, ONE USER. `token` is declared `unique`, so the platform \u2014\n// not this code \u2014 decides who wins a race. Two devices registering the\n// same token at the same instant produce exactly one row; the loser sees\n// 409 `unique_violation`.\n// 2. RE-OWN ON CHANGE. A token that comes back under a DIFFERENT user is\n// REASSIGNED, not duplicated. This is the case that matters: a resold\n// phone, a shared tablet, a sign-out/sign-in on the same handset. Without\n// it, the previous owner's notifications keep arriving on someone else's\n// lock screen.\n//\n// IDENTITY. If the call carries a verified end-user session, `envelope.end_user`\n// is populated by the platform and THAT id wins \u2014 a body field can never\n// impersonate a signed-in user. In server mode (your backend, a server key)\n// there is no verified principal and `user_id` from the body is used.\n//\n// SERVER MODE re-owns immediately (a tenant-wide read finds the row whoever\n// owns it). END-USER MODE cannot see another user's row \u2014 the owner scope is\n// the fail-safe working \u2014 so the app's call files a CLAIM in\n// `push_token_claims` (owned by the caller) and answers 202\n// `{ action: 'reown_queued' }`; the cmsHook below finishes the move.\n//\n// \u2500\u2500 cmsHook: finish an async re-own \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Runs in SERVER mode (a platform-delivered hook carries no end-user), so it can\n// read and move any row: re-read the claim, re-own (or create) the token row for\n// the claimant, delete the claim. At-least-once and re-delivered on failure\n// (`retry`), so every step is idempotent: a claim that is already gone is done.\n//\n// \u2500\u2500 webhook: erasure and deletion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// `auth.user.erased { user_id }`, `user.erased { id }` and `user.deleted { id }`\n// (the admin delete without erase) delete every device row, pending claim and\n// the preferences row of that user \u2014 device tokens, time zone and activity are\n// personal data, and a deleted account must stop getting reminder nudges.\n// Deliberately NOT `auth.session.revoked`: that event fires on every\n// refresh-token rotation, not only on sign-out. A MERGE or REKEY\n// (`auth.user.merged` / `.rekeyed`, guest \u2192 registered) needs nothing here: the\n// app's next start registers its token under the new id, which re-owns it.\n\nimport type { CmsHookFunctionEnvelope, HttpFunctionEnvelope, WebhookFunctionEnvelope } from '@vxil/sdk';\n\ntype RegisterBody = { token?: string; user_id?: string; platform?: string; locale?: string };\ntype Env =\n | HttpFunctionEnvelope<RegisterBody>\n | CmsHookFunctionEnvelope\n | WebhookFunctionEnvelope<{ user_id?: string; id?: string }>;\ninterface TokenRow { item_id: string; version?: number; data: { token?: string; end_user?: string } }\ninterface ClaimRow { item_id: string; data: { token?: string; end_user?: string; platform?: string; locale?: string } }\n\nconst PLATFORMS = new Set(['ios', 'android', 'web']);\n/** Rows deleted per erasure attempt \u2014 a person has a handful of devices; a\n * bigger set is finished by the next attempt (each deletes the next pages). */\nconst ERASE_MAX = 200;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n if (env.trigger === 'cms-hook') return finishClaim(base, H, env);\n if (env.trigger === 'webhook') return forgetUser(base, H, env);\n return register(base, H, env as HttpFunctionEnvelope<RegisterBody>);\n },\n};\n\n// \u2500\u2500 http \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function register(base: string, H: Record<string, string>, env: HttpFunctionEnvelope<RegisterBody>): Promise<Response> {\n const p = env.payload ?? {};\n const token = typeof p.token === 'string' ? p.token.trim() : '';\n // A VERIFIED principal always beats the body. Never trust a client-supplied\n // user id when the platform has already told you who is calling.\n const endUser = env.end_user?.id;\n const owner = endUser ?? (typeof p.user_id === 'string' ? p.user_id.trim() : '');\n if (!token) return json({ error: 'token is required' }, 400);\n if (!owner) return json({ error: 'user_id is required (or invoke with an end-user session)' }, 400);\n // The unique claim is capped at 256 chars; refuse early with a clear message\n // rather than letting the write fail deep in the stack.\n if (token.length > 256) return json({ error: 'token longer than 256 characters \u2014 store a hash instead' }, 400);\n\n const data = tokenData(token, owner, p.platform, p.locale);\n\n // \u2500\u2500 1. Try to create. The common case is one round-trip. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const created = await fetch(`${base}/v1/cms/items/push_tokens`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n });\n if (created.ok) {\n const body = (await created.json()) as { data?: { item_id?: string } };\n return json({ registered: true, action: 'created', item_id: body.data?.item_id }, 201);\n }\n if (created.status !== 409) return json({ error: 'register_failed', status: created.status }, 502);\n\n // \u2500\u2500 2. 409 \u21D2 the token already exists. Find it and decide. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // In SERVER mode this read is tenant-wide and finds the row whoever owns it.\n // In end-user mode it is owner-scoped, so a row belonging to someone else is\n // invisible \u2014 that is the fail-safe working as designed.\n const row = await findToken(base, H, token);\n if (row === 'error') return json({ error: 'lookup_failed' }, 502);\n if (!row) {\n if (!endUser) {\n // Server mode sees every row, so \"exists but not found\" is a race with a\n // concurrent delete. Retrying the call settles it.\n return json({ registered: false, error: 'conflict', hint: 'retry the call' }, 409);\n }\n // \u2605 The async re-own. File a claim (owned by the caller \u2014 the platform\n // stamps the verified owner); the server-mode cmsHook moves the token.\n const claim = await fetch(`${base}/v1/cms/items/push_token_claims`, {\n method: 'POST', headers: H,\n body: JSON.stringify({ status: 'published', data: claimData(token, owner, p.platform, p.locale) }),\n });\n if (!claim.ok) return json({ error: 'claim_failed', status: claim.status }, 502);\n const c = (await claim.json()) as { data?: { item_id?: string } };\n return json({ registered: false, action: 'reown_queued', claim_id: c.data?.item_id }, 202);\n }\n\n const moved = await reown(base, H, row, data);\n if (!moved.ok) {\n // 409 version_conflict = a concurrent registration already moved it. The\n // registry is still correct; the caller can simply retry.\n return json({ registered: false, error: 'conflict', status: moved.status }, 409);\n }\n const previous = row.data.end_user;\n return json({\n registered: true,\n action: previous && previous !== owner ? 'reowned' : 'refreshed',\n item_id: row.item_id,\n ...(previous && previous !== owner ? { previous_owner: previous } : {}),\n }, 200);\n}\n\n// \u2500\u2500 cmsHook \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function finishClaim(base: string, H: Record<string, string>, env: CmsHookFunctionEnvelope): Promise<Response> {\n const { collection, item_id: claimId } = env.payload ?? ({} as CmsHookFunctionEnvelope['payload']);\n // The platform filters on the binding's collection; this guard is belt and braces.\n if (collection !== 'push_token_claims' || !claimId) return json({ skipped: true }, 200);\n\n const got = await fetch(`${base}/v1/cms/items/push_token_claims/${claimId}`, { headers: H });\n if (got.status === 404) return json({ done: true, reason: 'claim already handled' }, 200);\n if (!got.ok) return json({ error: 'claim_read_failed', status: got.status }, 502); // non-2xx \u2192 another attempt (maxAttempts: 3)\n const claim = ((await got.json()) as { data?: ClaimRow }).data;\n const token = claim?.data.token;\n const owner = claim?.data.end_user;\n if (!token || !owner) {\n await deleteItem(base, H, 'push_token_claims', claimId);\n return json({ done: true, reason: 'empty claim dropped' }, 200);\n }\n\n const data = tokenData(token, owner, claim.data.platform, claim.data.locale);\n const row = await findToken(base, H, token);\n if (row === 'error') return json({ error: 'lookup_failed' }, 502); // non-2xx \u2192 another attempt (maxAttempts: 3)\n let action: string;\n if (!row) {\n // The holder deleted it in the meantime: the claimant simply registers it.\n const created = await fetch(`${base}/v1/cms/items/push_tokens`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n });\n // 409 = it was registered again concurrently; the next delivery re-reads.\n if (!created.ok) return json({ error: 'create_failed', status: created.status }, 502);\n action = 'created';\n } else {\n const moved = await reown(base, H, row, data);\n if (!moved.ok) return json({ error: 'reown_failed', status: moved.status }, 502); // another attempt re-reads (maxAttempts: 3)\n action = row.data.end_user === owner ? 'refreshed' : 'reowned';\n }\n await deleteItem(base, H, 'push_token_claims', claimId);\n return json({ done: true, action, claim_id: claimId }, 200);\n}\n\n// \u2500\u2500 webhook \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function forgetUser(\n base: string, H: Record<string, string>, env: WebhookFunctionEnvelope<{ user_id?: string; id?: string }>,\n): Promise<Response> {\n const event = env.payload?.event;\n const d = env.payload?.data;\n const fields = d && !('truncated' in d) ? d : null;\n // auth.user.erased carries `user_id`; the admin erase and delete carry `id`.\n const userId = event === 'auth.user.erased' ? fields?.user_id\n : event === 'user.erased' || event === 'user.deleted' ? fields?.id : undefined;\n if (typeof userId !== 'string' || !userId) return json({ skipped: true, event }, 200);\n\n let deleted = 0;\n let failed = 0;\n for (const coll of ['push_tokens', 'push_token_claims', 'push_prefs']) {\n const filter = encodeURIComponent(JSON.stringify({ end_user: userId }));\n while (deleted + failed < ERASE_MAX) {\n // Always the FIRST page: what was deleted is gone, so the next read\n // returns the next rows. A row that refuses to delete ends the loop.\n const res = await fetch(`${base}/v1/cms/items/${coll}?filter=${filter}&limit=50`, { headers: H });\n if (!res.ok) return json({ error: 'list_failed', status: res.status, deleted }, 502); // non-2xx \u2192 another attempt (maxAttempts: 3)\n const items = ((await res.json()) as { data?: { items?: Array<{ item_id: string }> } }).data?.items ?? [];\n if (items.length === 0) break;\n let progress = 0;\n for (const it of items) {\n if (await deleteItem(base, H, coll, it.item_id)) { deleted++; progress++; } else failed++;\n }\n if (progress === 0) break;\n }\n }\n // A failed delete \u2014 or a set larger than one attempt's ERASE_MAX \u2014 answers\n // non-2xx: the binding declares maxAttempts: 3, so the jobs ladder hands it\n // back for another attempt, which deletes the rest.\n const unfinished = failed > 0 || deleted >= ERASE_MAX;\n return json({ user_id: userId, deleted, failed, done: !unfinished }, unfinished ? 502 : 200);\n}\n\n// \u2500\u2500 helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction tokenData(token: string, owner: string, platform: unknown, locale: unknown): Record<string, unknown> {\n return {\n token, end_user: owner, last_seen: new Date().toISOString(), failures: 0, last_error: '',\n ...(typeof platform === 'string' && PLATFORMS.has(platform) ? { platform } : {}),\n ...(typeof locale === 'string' && locale ? { locale: locale.slice(0, 16) } : {}),\n };\n}\n\nfunction claimData(token: string, owner: string, platform: unknown, locale: unknown): Record<string, unknown> {\n return {\n token, end_user: owner,\n ...(typeof platform === 'string' && PLATFORMS.has(platform) ? { platform } : {}),\n ...(typeof locale === 'string' && locale ? { locale: locale.slice(0, 16) } : {}),\n };\n}\n\nasync function findToken(base: string, H: Record<string, string>, token: string): Promise<TokenRow | null | 'error'> {\n const filter = encodeURIComponent(JSON.stringify({ token }));\n const found = await fetch(`${base}/v1/cms/items/push_tokens?filter=${filter}&limit=1`, { headers: H });\n if (!found.ok) return 'error';\n return ((await found.json()) as { data?: { items?: TokenRow[] } }).data?.items?.[0] ?? null;\n}\n\n/** \u2605 INVARIANT 2: re-own (or just refresh, when the owner is unchanged).\n * If-Match makes the reassignment atomic against a concurrent registration. */\nfunction reown(base: string, H: Record<string, string>, row: TokenRow, data: Record<string, unknown>): Promise<Response> {\n return fetch(`${base}/v1/cms/items/push_tokens/${row.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(row.version !== undefined ? { 'if-match': String(row.version) } : {}) },\n body: JSON.stringify({ data }),\n });\n}\n\n/** true when the row is gone afterwards (a 404 counts: someone else deleted it). */\nasync function deleteItem(base: string, H: Record<string, string>, coll: string, itemId: string): Promise<boolean> {\n const res = await fetch(`${base}/v1/cms/items/${coll}/${itemId}`, { method: 'DELETE', headers: H }).catch(() => null);\n return !!res && (res.ok || res.status === 404);\n}\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
20269
- "send-push.ts": "// send-push.ts \u2014 THE SENDER (a vxil function, two bindings).\n//\n// http POST /v1/fn/send-push \u2014 send now (below).\n// cron `0 * * * *` \u2014 THE REMINDER LADDER (further below).\n//\n// POST /v1/fn/send-push\n// {\n// \"user_ids\": [\"usr_a\", \"usr_b\"],\n// \"messages\": { // keyed by locale; \"default\" is required\n// \"default\": { \"title\": \"You're in\", \"body\": \"Pro is active on this device.\" },\n// \"ar\": { \"title\": \"\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644\", \"body\": \"\u0623\u0635\u0628\u062D \u0627\u0634\u062A\u0631\u0627\u0643 Pro \u0645\u0641\u0639\u0651\u0644\u0627\u064B.\" },\n// \"fr\": { \"title\": \"C'est actif\", \"body\": \"Pro est activ\xE9 sur cet appareil.\" }\n// },\n// \"data\": { \"screen\": \"billing\" } // optional payload handed to your app\n// }\n//\n// It resolves each user's live devices, picks the copy for THAT DEVICE'S locale\n// (captured at registration), and posts to the push service in batches.\n//\n// LOCALIZATION IS PER DEVICE, NOT PER USER. One person can carry an English\n// phone and an Arabic tablet. Because `locale` lives on the token row, the right\n// copy goes to each device with no extra lookup.\n//\n// Two failure kinds, handled differently:\n// \u2022 an IMMEDIATE per-message error (`DeviceNotRegistered`) \u2014 the token is dead\n// right now; delete the row.\n// \u2022 an accepted message \u2014 the service returns a RECEIPT id, and the real\n// verdict arrives later. Store the id (and when); `prune-receipts` collects it.\n//\n// \u2500\u2500 The reminder ladder (cron, hourly) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Re-engagement for users who stopped opening the app: one nudge after 1 day\n// of inactivity, one after 3, one after 7 \u2014 then silence until they come back.\n// Per user, from `push_prefs` (the app writes its own row):\n// \u2022 `reminders: false` \u2192 never;\n// \u2022 quiet hours [quiet_start, quiet_end) in the user's `timezone` \u2192 not now\n// (the next hourly tick after the window sends it);\n// \u2022 `last_active` + `ladder_step` decide the step; the APP resets\n// `ladder_step` to 0 when it comes to the foreground.\n// ONE nudge per tick at most, and never a catch-up burst: when several rungs\n// are due at once (a user already idle for a week when the ladder first sees\n// them \u2014 the kit added to a live app, reminders turned back on, a held cron),\n// only the HIGHEST due rung is sent and the steps below it are skipped. And a\n// rung waits its own gap after the previous nudge (`last_reminded_at`), so\n// 1 d \u2192 3 d \u2192 7 d stay 2 and 4 days apart even when a step went out late.\n// Each step is CLAIMED with If-Match on the prefs row BEFORE the send, so two\n// overlapping ticks can never send it twice (a send that then fails is a\n// missed nudge, never a duplicate one). The walk only reads users inactive for\n// at least the first step and not yet through the ladder (`ladder_step` below\n// its length \u2014 a `derive` hook writes 0 when the app leaves it out), and\n// resumes where the last tick stopped (the cursor lives in `push_state`), so\n// it covers any number of users over successive ticks: MAX_LADDER_ROWS read and\n// MAX_REMINDERS sent per tick. A skipped tick is harmless \u2014 the next resumes.\n\n// Expo's push endpoint. For raw FCM: 'https://fcm.googleapis.com/v1/projects/<id>/messages:send'\n// (and an OAuth bearer). For APNs: 'https://api.push.apple.com/3/device/<token>'\n// (one request per device, a JWT `authorization`, and an `apns-topic` header).\n// Whichever you pick, its HOST must be in the function's `egressAllow`.\n\n// cron-walk: persisted-cursor \u2014 the ladder walk's cursor lives in `push_state` and is saved with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst PUSH_URL = 'https://exp.host/--/api/v2/push/send';\n/** Expo accepts up to 100 messages in one request. */\nconst BATCH = 100;\n/** cms page size \u2014 100 is the default `maxPageSize` for the cms feature. */\nconst PAGE = 100;\n/** Devices resolved per invocation \u2014 a guardrail, not a limit of the platform. */\nconst MAX_DEVICES = 1000;\n\n/** The ladder: hours of inactivity \u2192 the nudge, per locale ('default' required).\n * Edit the copy; keep the hours ascending. */\nconst LADDER: ReadonlyArray<{ afterHours: number; messages: Messages }> = [\n { afterHours: 24, messages: {\n default: { title: 'Pick up where you left off', body: 'Your streak is waiting for you.' },\n ar: { title: '\u0623\u0643\u0645\u0644 \u0645\u0646 \u062D\u064A\u062B \u062A\u0648\u0642\u0641\u062A', body: '\u0633\u0644\u0633\u0644\u062A\u0643 \u0628\u0627\u0646\u062A\u0638\u0627\u0631\u0643.' },\n } },\n { afterHours: 72, messages: {\n default: { title: 'We saved your progress', body: 'It only takes a minute to get back on track.' },\n ar: { title: '\u062D\u0641\u0638\u0646\u0627 \u062A\u0642\u062F\u0645\u0643', body: '\u062F\u0642\u064A\u0642\u0629 \u0648\u0627\u062D\u062F\u0629 \u062A\u0643\u0641\u064A \u0644\u0644\u0639\u0648\u062F\u0629.' },\n } },\n { afterHours: 168, messages: {\n default: { title: 'Still there?', body: 'Here is what you missed this week.' },\n ar: { title: '\u0647\u0644 \u0645\u0627 \u0632\u0644\u062A \u0647\u0646\u0627\u061F', body: '\u0625\u0644\u064A\u0643 \u0645\u0627 \u0641\u0627\u062A\u0643 \u0647\u0630\u0627 \u0627\u0644\u0623\u0633\u0628\u0648\u0639.' },\n } },\n];\n/** `push_prefs` rows read per tick. */\nconst MAX_LADDER_ROWS = 2000;\n/** Users nudged per tick (each costs a claim PATCH, a device read and receipt PATCHes). */\nconst MAX_REMINDERS = 100;\n/** This cron's row in `push_state`. */\nconst LADDER_STATE_KEY = 'reminder-ladder';\n\ntype Copy = { title?: string; body?: string };\ntype Messages = Record<string, Copy>;\ntype Env = CronFunctionEnvelope | HttpFunctionEnvelope<{\n user_ids?: string[];\n messages?: Messages;\n data?: Record<string, unknown>;\n}>;\ninterface TokenRow {\n item_id: string;\n version?: number;\n data: { token?: string; end_user?: string; locale?: string; failures?: number };\n}\n/** One entry of Expo's response array, positionally matched to the request. */\ninterface Ticket { status?: string; id?: string; message?: string; details?: { error?: string } }\ninterface PrefRow {\n item_id: string;\n version?: number;\n data: {\n end_user?: string; timezone?: string; reminders?: boolean; quiet_start?: number; quiet_end?: number;\n last_active?: string; ladder_step?: number; last_reminded_at?: string;\n };\n}\ninterface StateRow { item_id: string; version?: number; data: { cursor?: string } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n if (env.trigger === 'cron') return ladder(base, H);\n\n const p = (env as Exclude<Env, CronFunctionEnvelope>).payload ?? {};\n const userIds = Array.isArray(p.user_ids) ? p.user_ids.filter((u) => typeof u === 'string' && u) : [];\n const messages = p.messages ?? {};\n if (userIds.length === 0) return json({ error: 'user_ids is required' }, 400);\n if (!messages.default?.title && !messages.default?.body) {\n return json({ error: 'messages.default { title, body } is required' }, 400);\n }\n const devices = await resolveDevices(base, H, userIds);\n if (devices.length === 0) return json({ sent: 0, devices: 0, note: 'no registered devices' }, 200);\n const out = await sendTo(base, H, devices.map((d) => ({ device: d, messages })), p.data);\n return json({ devices: devices.length, ...out }, 200);\n },\n};\n\n/** \u2500\u2500 1. Resolve devices. One filtered read per user (owner-scoped by value,\n * not by principal \u2014 this runs in server mode). */\nasync function resolveDevices(base: string, H: Record<string, string>, userIds: string[]): Promise<TokenRow[]> {\n const devices: TokenRow[] = [];\n for (const uid of userIds) {\n if (devices.length >= MAX_DEVICES) break;\n const filter = encodeURIComponent(JSON.stringify({ end_user: uid }));\n const res = await fetch(`${base}/v1/cms/items/push_tokens?filter=${filter}&limit=50`, { headers: H });\n if (!res.ok) continue;\n const items = ((await res.json()) as { data?: { items?: TokenRow[] } }).data?.items ?? [];\n for (const it of items) if (it.data.token) devices.push(it);\n }\n return devices;\n}\n\n/** \u2500\u2500 2 + 3. One message per device in that device's language; send in\n * batches; reconcile each ticket back to its device by index. */\nasync function sendTo(\n base: string, H: Record<string, string>,\n targets: Array<{ device: TokenRow; messages: Messages }>, data?: Record<string, unknown>,\n): Promise<{ accepted: number; dropped: number; errors: string[] }> {\n const outgoing = targets.map(({ device, messages }) => {\n const copy = pick(messages, device.data.locale);\n return {\n to: device.data.token!,\n ...(copy.title ? { title: copy.title } : {}),\n ...(copy.body ? { body: copy.body } : {}),\n ...(data ? { data } : {}),\n sound: 'default',\n };\n });\n let accepted = 0;\n let dropped = 0;\n const errors: string[] = [];\n for (let i = 0; i < outgoing.length; i += BATCH) {\n const slice = outgoing.slice(i, i + BATCH);\n const res = await fetch(PUSH_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify(slice),\n }).catch(() => null);\n if (!res || !res.ok) {\n errors.push(`push service returned ${res?.status ?? 'network error'} for batch ${i / BATCH}`);\n continue;\n }\n const tickets = (((await res.json().catch(() => ({}))) as { data?: Ticket[] }).data) ?? [];\n for (let k = 0; k < slice.length; k++) {\n const device = targets[i + k]!.device;\n const ticket = tickets[k];\n if (ticket?.status === 'ok' && ticket.id) {\n // Accepted, verdict pending. Park the receipt id (and when) for prune-receipts.\n accepted++;\n await patch(base, H, device, { receipt_id: ticket.id, receipt_at: new Date().toISOString(), failures: 0, last_error: '' });\n continue;\n }\n const reason = ticket?.details?.error ?? ticket?.message ?? 'unknown';\n if (reason === 'DeviceNotRegistered') {\n // Definitive: this token will never deliver again. \u2605 INVARIANT 3.\n await del(base, H, device);\n dropped++;\n continue;\n }\n // Anything else (MessageTooBig, MessageRateExceeded, a transient fault)\n // is NOT a reason to delete a device. Count it and move on.\n await patch(base, H, device, {\n failures: (device.data.failures ?? 0) + 1,\n last_error: String(reason).slice(0, 300),\n });\n errors.push(reason);\n }\n }\n return { accepted, dropped, errors: errors.slice(0, 10) };\n}\n\n// \u2500\u2500 The reminder ladder \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function ladder(base: string, H: Record<string, string>): Promise<Response> {\n const now = new Date();\n const state = await readState(base, H, LADDER_STATE_KEY);\n if (state === 'error') return json({ error: 'state_read_failed' }, 502);\n const startedAt = state?.data.cursor || null;\n // Only users idle for at least the first step, with a rung still ahead of\n // them, can be due: both terms ride slot indexes (`last_active` t1,\n // `ladder_step` n1), so active users and finished ladders are never read.\n const idleSince = new Date(now.getTime() - LADDER[0]!.afterHours * 3_600_000).toISOString();\n const filter = encodeURIComponent(JSON.stringify({\n last_active: { $lt: idleSince }, ladder_step: { $lt: LADDER.length },\n }));\n\n const due: Array<{ row: PrefRow; step: number }> = [];\n let cursor: string | null = startedAt;\n let scanned = 0;\n let wrapped = false;\n let quiet = 0;\n scan: while (scanned < MAX_LADDER_ROWS) {\n const res = await fetch(`${base}/v1/cms/items/push_prefs?filter=${filter}&limit=${PAGE}`\n + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''), { headers: H });\n if (!res.ok) return json({ error: 'list_failed', status: res.status }, 502);\n const body = (await res.json()) as { data?: { items?: PrefRow[]; next_cursor?: string | null } };\n const items = body.data?.items ?? [];\n for (const it of items) {\n scanned++;\n cursor = it.item_id;\n const step = dueStep(it.data, now);\n if (step !== null) {\n if (inQuietHours(it.data, now)) quiet++; // left for a later tick: the cursor wraps back to it\n else due.push({ row: it, step });\n }\n if (due.length >= MAX_REMINDERS || scanned >= MAX_LADDER_ROWS) break scan;\n }\n if (!body.data?.next_cursor || items.length === 0) { wrapped = true; break; }\n cursor = body.data.next_cursor;\n }\n const nextCursor = wrapped ? '' : (cursor ?? '');\n\n // Claim each step FIRST (If-Match): a concurrent tick that claimed it wins,\n // and this one skips the user \u2014 never a double nudge.\n const targets: Array<{ device: TokenRow; messages: Messages }> = [];\n let claimed = 0;\n for (const { row, step } of due) {\n const res = await fetch(`${base}/v1/cms/items/push_prefs/${row.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(row.version !== undefined ? { 'if-match': String(row.version) } : {}) },\n body: JSON.stringify({ data: { ladder_step: step + 1, last_reminded_at: now.toISOString() } }),\n }).catch(() => null);\n if (!res || !res.ok) continue;\n claimed++;\n for (const device of await resolveDevices(base, H, [row.data.end_user!])) {\n targets.push({ device, messages: LADDER[step]!.messages });\n }\n }\n const sent = targets.length > 0\n ? await sendTo(base, H, targets, { reason: 'reminder' })\n : { accepted: 0, dropped: 0, errors: [] as string[] };\n const saved = await writeState(base, H, LADDER_STATE_KEY, state, nextCursor);\n return json({\n ladder: true, scanned, due: due.length, quiet, claimed, devices: targets.length, ...sent,\n resumed_from: startedAt, cursor: nextCursor, wrapped, saved,\n }, 200);\n}\n\n/** The ladder step due for this user now, or null. Exported for tests.\n * The HIGHEST rung whose idle threshold is met (the claim then moves\n * `ladder_step` past it, so the rungs below are skipped, never sent late), and\n * only once that rung's gap since the previous nudge has passed. */\nexport function dueStep(p: PrefRow['data'], now: Date): number | null {\n if (p.reminders === false || !p.end_user || !p.last_active) return null;\n const idleH = (now.getTime() - Date.parse(p.last_active)) / 3_600_000;\n if (!Number.isFinite(idleH)) return null;\n const from = Math.max(0, Math.trunc(p.ladder_step ?? 0));\n let step: number | null = null;\n for (let r = from; r < LADDER.length; r++) if (idleH >= LADDER[r]!.afterHours) step = r;\n if (step === null) return null;\n if (step > 0 && p.last_reminded_at) {\n const sinceH = (now.getTime() - Date.parse(p.last_reminded_at)) / 3_600_000;\n const gapH = LADDER[step]!.afterHours - LADDER[step - 1]!.afterHours;\n if (Number.isFinite(sinceH) && sinceH < gapH) return null;\n }\n return step;\n}\n\n/** Is it inside the user's quiet hours [quiet_start, quiet_end) in THEIR zone?\n * The window may wrap midnight; equal or missing bounds mean none. An unknown\n * zone is evaluated in UTC. Exported for tests. */\nexport function inQuietHours(p: PrefRow['data'], now: Date): boolean {\n const start = p.quiet_start;\n const end = p.quiet_end;\n if (typeof start !== 'number' || typeof end !== 'number' || start === end) return false;\n const h = localHour(now, p.timezone);\n return start < end ? h >= start && h < end : h >= start || h < end;\n}\n\n/** One formatter per zone, kept for the isolate's life: building an Intl formatter is the\n * expensive part, and a tick can test hundreds of users in the same few zones\n * (CPU per invocation is capped \u2014 2,000 ms on a Free staging/development project, 50 ms on a Free dev slot). */\nconst ZONE_FMT = new Map<string, Intl.DateTimeFormat | null>();\nfunction zoneFormatter(tz: string): Intl.DateTimeFormat | null {\n if (!ZONE_FMT.has(tz)) {\n let f: Intl.DateTimeFormat | null = null;\n try { f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', hourCycle: 'h23' }); } catch { /* unknown zone */ }\n if (ZONE_FMT.size < 500) ZONE_FMT.set(tz, f);\n return f;\n }\n return ZONE_FMT.get(tz)!;\n}\n\nfunction localHour(now: Date, tz: string | undefined): number {\n try {\n const part = zoneFormatter(tz || 'UTC')?.formatToParts(now).find((x) => x.type === 'hour');\n const h = Number(part?.value);\n if (Number.isInteger(h)) return h % 24;\n } catch { /* unknown zone \u2192 UTC below */ }\n return now.getUTCHours();\n}\n\nasync function readState(base: string, H: Record<string, string>, key: string): Promise<StateRow | null | 'error'> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${base}/v1/cms/items/push_state?filter=${filter}&limit=1`, { headers: H });\n if (!res.ok) return 'error';\n return ((await res.json()) as { data?: { items?: StateRow[] } }).data?.items?.[0] ?? null;\n}\n\nasync function writeState(\n base: string, H: Record<string, string>, key: string, state: StateRow | null, cursor: string,\n): Promise<boolean> {\n const data = { key, cursor, updated_at: new Date().toISOString() };\n const res = state\n ? await fetch(`${base}/v1/cms/items/push_state/${state.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(state.version !== undefined ? { 'if-match': String(state.version) } : {}) },\n body: JSON.stringify({ data }),\n }).catch(() => null)\n : await fetch(`${base}/v1/cms/items/push_state`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n }).catch(() => null);\n return !!res && res.ok;\n}\n\n/** Locale match: exact ('pt-BR'), then the base language ('pt'), then default. */\nfunction pick(messages: Messages, locale: string | undefined): Copy {\n if (locale) {\n if (messages[locale]) return messages[locale]!;\n const bare = locale.split('-')[0]!;\n if (messages[bare]) return messages[bare]!;\n }\n return messages.default ?? {};\n}\n\nasync function patch(base: string, H: Record<string, string>, d: TokenRow, data: Record<string, unknown>) {\n await fetch(`${base}/v1/cms/items/push_tokens/${d.item_id}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data }),\n }).catch(() => null);\n}\nasync function del(base: string, H: Record<string, string>, d: TokenRow) {\n await fetch(`${base}/v1/cms/items/push_tokens/${d.item_id}`, { method: 'DELETE', headers: H }).catch(() => null);\n}\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n"
20839
+ "send-push.ts": "// send-push.ts \u2014 THE SENDER (a vxil function, two bindings).\n//\n// http POST /v1/fn/send-push \u2014 send now (below).\n// cron `0 * * * *` \u2014 THE REMINDER LADDER (further below).\n//\n// POST /v1/fn/send-push\n// {\n// \"user_ids\": [\"usr_a\", \"usr_b\"],\n// \"messages\": { // keyed by locale; \"default\" is required\n// \"default\": { \"title\": \"You're in\", \"body\": \"Pro is active on this device.\" },\n// \"ar\": { \"title\": \"\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644\", \"body\": \"\u0623\u0635\u0628\u062D \u0627\u0634\u062A\u0631\u0627\u0643 Pro \u0645\u0641\u0639\u0651\u0644\u0627\u064B.\" },\n// \"fr\": { \"title\": \"C'est actif\", \"body\": \"Pro est activ\xE9 sur cet appareil.\" }\n// },\n// \"data\": { \"screen\": \"billing\" } // optional payload handed to your app\n// }\n//\n// It resolves each user's live devices, picks the copy for THAT DEVICE'S locale\n// (captured at registration), and posts to the push service in batches.\n//\n// LOCALIZATION IS PER DEVICE, NOT PER USER. One person can carry an English\n// phone and an Arabic tablet. Because `locale` lives on the token row, the right\n// copy goes to each device with no extra lookup.\n//\n// Two failure kinds, handled differently:\n// \u2022 an IMMEDIATE per-message error (`DeviceNotRegistered`) \u2014 the token is dead\n// right now; delete the row.\n// \u2022 an accepted message \u2014 the service returns a RECEIPT id, and the real\n// verdict arrives later. Store the id (and when); `prune-receipts` collects it.\n//\n// \u2500\u2500 The reminder ladder (cron, hourly) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Re-engagement for users who stopped opening the app: one nudge after 1 day\n// of inactivity, one after 3, one after 7 \u2014 then silence until they come back.\n// Per user, from `push_prefs` (the app writes its own row):\n// \u2022 `reminders: false` \u2192 never;\n// \u2022 quiet hours [quiet_start, quiet_end) in the user's `timezone` \u2192 not now\n// (the next hourly tick after the window sends it);\n// \u2022 `last_active` + `ladder_step` decide the step; the APP resets\n// `ladder_step` to 0 when it comes to the foreground.\n// ONE nudge per tick at most, and never a catch-up burst: when several rungs\n// are due at once (a user already idle for a week when the ladder first sees\n// them \u2014 the kit added to a live app, reminders turned back on, a held cron),\n// only the HIGHEST due rung is sent and the steps below it are skipped. And a\n// rung waits its own gap after the previous nudge (`last_reminded_at`), so\n// 1 d \u2192 3 d \u2192 7 d stay 2 and 4 days apart even when a step went out late.\n// Each step is CLAIMED with If-Match on the prefs row BEFORE the send, so two\n// overlapping ticks can never send it twice (a send that then fails is a\n// missed nudge, never a duplicate one). The walk only reads users inactive for\n// at least the first step and not yet through the ladder (`ladder_step` below\n// its length \u2014 a `derive` hook writes 0 when the app leaves it out), and\n// resumes where the last tick stopped (the cursor lives in `push_state`), so\n// it covers any number of users over successive ticks: MAX_LADDER_ROWS read and\n// MAX_REMINDERS sent per tick. A skipped tick is harmless \u2014 the next resumes.\n\n// Expo's push endpoint. For raw FCM: 'https://fcm.googleapis.com/v1/projects/<id>/messages:send'\n// (and an OAuth bearer). For APNs: 'https://api.push.apple.com/3/device/<token>'\n// (one request per device, a JWT `authorization`, and an `apns-topic` header).\n// Whichever you pick, its HOST must be in the function's `egressAllow`.\n\n// cron-walk: persisted-cursor \u2014 the ladder walk's cursor lives in `push_state` and is saved with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst PUSH_URL = 'https://exp.host/--/api/v2/push/send';\n/** Expo accepts up to 100 messages in one request. */\nconst BATCH = 100;\n/** cms page size \u2014 100 is the default `maxPageSize` for the cms feature. */\nconst PAGE = 100;\n/** Devices resolved per invocation \u2014 a guardrail, not a limit of the platform. */\nconst MAX_DEVICES = 1000;\n\n/** The ladder: hours of inactivity \u2192 the nudge, per locale ('default' required).\n * Edit the copy; keep the hours ascending. */\nconst LADDER: ReadonlyArray<{ afterHours: number; messages: Messages }> = [\n { afterHours: 24, messages: {\n default: { title: 'Pick up where you left off', body: 'Your streak is waiting for you.' },\n ar: { title: '\u0623\u0643\u0645\u0644 \u0645\u0646 \u062D\u064A\u062B \u062A\u0648\u0642\u0641\u062A', body: '\u0633\u0644\u0633\u0644\u062A\u0643 \u0628\u0627\u0646\u062A\u0638\u0627\u0631\u0643.' },\n } },\n { afterHours: 72, messages: {\n default: { title: 'We saved your progress', body: 'It only takes a minute to get back on track.' },\n ar: { title: '\u062D\u0641\u0638\u0646\u0627 \u062A\u0642\u062F\u0645\u0643', body: '\u062F\u0642\u064A\u0642\u0629 \u0648\u0627\u062D\u062F\u0629 \u062A\u0643\u0641\u064A \u0644\u0644\u0639\u0648\u062F\u0629.' },\n } },\n { afterHours: 168, messages: {\n default: { title: 'Still there?', body: 'Here is what you missed this week.' },\n ar: { title: '\u0647\u0644 \u0645\u0627 \u0632\u0644\u062A \u0647\u0646\u0627\u061F', body: '\u0625\u0644\u064A\u0643 \u0645\u0627 \u0641\u0627\u062A\u0643 \u0647\u0630\u0627 \u0627\u0644\u0623\u0633\u0628\u0648\u0639.' },\n } },\n];\n/** `push_prefs` rows read per tick. */\nconst MAX_LADDER_ROWS = 2000;\n/** Users nudged per tick (each costs a claim PATCH, a device read and receipt PATCHes). */\nconst MAX_REMINDERS = 100;\n/** This cron's row in `push_state`. */\nconst LADDER_STATE_KEY = 'reminder-ladder';\n\ntype Copy = { title?: string; body?: string };\ntype Messages = Record<string, Copy>;\ntype Env = CronFunctionEnvelope | HttpFunctionEnvelope<{\n user_ids?: string[];\n messages?: Messages;\n data?: Record<string, unknown>;\n}>;\ninterface TokenRow {\n item_id: string;\n version?: number;\n data: { token?: string; end_user?: string; locale?: string; failures?: number };\n}\n/** One entry of Expo's response array, positionally matched to the request. */\ninterface Ticket { status?: string; id?: string; message?: string; details?: { error?: string } }\ninterface PrefRow {\n item_id: string;\n version?: number;\n data: {\n end_user?: string; timezone?: string; reminders?: boolean; quiet_start?: number; quiet_end?: number;\n last_active?: string; ladder_step?: number; last_reminded_at?: string;\n };\n}\ninterface StateRow { item_id: string; version?: number; data: { cursor?: string } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n if (env.trigger === 'cron') return ladder(base, H);\n\n const p = (env as Exclude<Env, CronFunctionEnvelope>).payload ?? {};\n const userIds = Array.isArray(p.user_ids) ? p.user_ids.filter((u) => typeof u === 'string' && u) : [];\n const messages = p.messages ?? {};\n if (userIds.length === 0) return json({ error: 'user_ids is required' }, 400);\n if (!messages.default?.title && !messages.default?.body) {\n return json({ error: 'messages.default { title, body } is required' }, 400);\n }\n const devices = await resolveDevices(base, H, userIds);\n if (devices.length === 0) return json({ sent: 0, devices: 0, note: 'no registered devices' }, 200);\n const out = await sendTo(base, H, devices.map((d) => ({ device: d, messages })), p.data);\n return json({ devices: devices.length, ...out }, 200);\n },\n};\n\n/** \u2500\u2500 1. Resolve devices. One filtered read per user (owner-scoped by value,\n * not by principal \u2014 this runs in server mode). */\nasync function resolveDevices(base: string, H: Record<string, string>, userIds: string[]): Promise<TokenRow[]> {\n const devices: TokenRow[] = [];\n for (const uid of userIds) {\n if (devices.length >= MAX_DEVICES) break;\n const filter = encodeURIComponent(JSON.stringify({ end_user: uid }));\n const res = await fetch(`${base}/v1/cms/items/push_tokens?filter=${filter}&limit=50`, { headers: H });\n if (!res.ok) continue;\n const items = ((await res.json()) as { data?: { items?: TokenRow[] } }).data?.items ?? [];\n for (const it of items) if (it.data.token) devices.push(it);\n }\n return devices;\n}\n\n/** \u2500\u2500 2 + 3. One message per device in that device's language; send in\n * batches; reconcile each ticket back to its device by index. */\nasync function sendTo(\n base: string, H: Record<string, string>,\n targets: Array<{ device: TokenRow; messages: Messages }>, data?: Record<string, unknown>,\n): Promise<{ accepted: number; dropped: number; errors: string[] }> {\n const outgoing = targets.map(({ device, messages }) => {\n const copy = pick(messages, device.data.locale);\n return {\n to: device.data.token!,\n ...(copy.title ? { title: copy.title } : {}),\n ...(copy.body ? { body: copy.body } : {}),\n ...(data ? { data } : {}),\n sound: 'default',\n };\n });\n let accepted = 0;\n let dropped = 0;\n const errors: string[] = [];\n for (let i = 0; i < outgoing.length; i += BATCH) {\n const slice = outgoing.slice(i, i + BATCH);\n const res = await fetch(PUSH_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify(slice),\n }).catch(() => null);\n if (!res || !res.ok) {\n errors.push(`push service returned ${res?.status ?? 'network error'} for batch ${i / BATCH}`);\n continue;\n }\n const tickets = (((await res.json().catch(() => ({}))) as { data?: Ticket[] }).data) ?? [];\n for (let k = 0; k < slice.length; k++) {\n const device = targets[i + k]!.device;\n const ticket = tickets[k];\n if (ticket?.status === 'ok' && ticket.id) {\n // Accepted, verdict pending. Park the receipt id (and when) for prune-receipts.\n accepted++;\n await patch(base, H, device, { receipt_id: ticket.id, receipt_at: new Date().toISOString(), failures: 0, last_error: '' });\n continue;\n }\n const reason = ticket?.details?.error ?? ticket?.message ?? 'unknown';\n if (reason === 'DeviceNotRegistered') {\n // Definitive: this token will never deliver again. \u2605 INVARIANT 3.\n await del(base, H, device);\n dropped++;\n continue;\n }\n // Anything else (MessageTooBig, MessageRateExceeded, a transient fault)\n // is NOT a reason to delete a device. Count it and move on.\n await patch(base, H, device, {\n failures: (device.data.failures ?? 0) + 1,\n last_error: String(reason).slice(0, 300),\n });\n errors.push(reason);\n }\n }\n return { accepted, dropped, errors: errors.slice(0, 10) };\n}\n\n// \u2500\u2500 The reminder ladder \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function ladder(base: string, H: Record<string, string>): Promise<Response> {\n const now = new Date();\n const state = await readState(base, H, LADDER_STATE_KEY);\n if (state === 'error') return json({ error: 'state_read_failed' }, 502);\n const startedAt = state?.data.cursor || null;\n // Only users idle for at least the first step, with a rung still ahead of\n // them, can be due: both terms ride slot indexes (`last_active` t1,\n // `ladder_step` n1), so active users and finished ladders are never read.\n const idleSince = new Date(now.getTime() - LADDER[0]!.afterHours * 3_600_000).toISOString();\n const filter = encodeURIComponent(JSON.stringify({\n last_active: { $lt: idleSince }, ladder_step: { $lt: LADDER.length },\n }));\n\n const due: Array<{ row: PrefRow; step: number }> = [];\n let cursor: string | null = startedAt;\n let scanned = 0;\n let wrapped = false;\n let quiet = 0;\n scan: while (scanned < MAX_LADDER_ROWS) {\n const res = await fetch(`${base}/v1/cms/items/push_prefs?filter=${filter}&limit=${PAGE}`\n + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''), { headers: H });\n if (!res.ok) return json({ error: 'list_failed', status: res.status }, 502);\n const body = (await res.json()) as { data?: { items?: PrefRow[]; next_cursor?: string | null } };\n const items = body.data?.items ?? [];\n for (const it of items) {\n scanned++;\n cursor = it.item_id;\n const step = dueStep(it.data, now);\n if (step !== null) {\n if (inQuietHours(it.data, now)) quiet++; // left for a later tick: the cursor wraps back to it\n else due.push({ row: it, step });\n }\n if (due.length >= MAX_REMINDERS || scanned >= MAX_LADDER_ROWS) break scan;\n }\n if (!body.data?.next_cursor || items.length === 0) { wrapped = true; break; }\n cursor = body.data.next_cursor;\n }\n const nextCursor = wrapped ? '' : (cursor ?? '');\n\n // Claim each step FIRST (If-Match): a concurrent tick that claimed it wins,\n // and this one skips the user \u2014 never a double nudge.\n const targets: Array<{ device: TokenRow; messages: Messages }> = [];\n let claimed = 0;\n for (const { row, step } of due) {\n const res = await fetch(`${base}/v1/cms/items/push_prefs/${row.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(row.version !== undefined ? { 'if-match': String(row.version) } : {}) },\n body: JSON.stringify({ data: { ladder_step: step + 1, last_reminded_at: now.toISOString() } }),\n }).catch(() => null);\n if (!res || !res.ok) continue;\n claimed++;\n for (const device of await resolveDevices(base, H, [row.data.end_user!])) {\n targets.push({ device, messages: LADDER[step]!.messages });\n }\n }\n const sent = targets.length > 0\n ? await sendTo(base, H, targets, { reason: 'reminder' })\n : { accepted: 0, dropped: 0, errors: [] as string[] };\n const saved = await writeState(base, H, LADDER_STATE_KEY, state, nextCursor);\n return json({\n ladder: true, scanned, due: due.length, quiet, claimed, devices: targets.length, ...sent,\n resumed_from: startedAt, cursor: nextCursor, wrapped, saved,\n }, 200);\n}\n\n/** The ladder step due for this user now, or null. Exported for tests.\n * The HIGHEST rung whose idle threshold is met (the claim then moves\n * `ladder_step` past it, so the rungs below are skipped, never sent late), and\n * only once that rung's gap since the previous nudge has passed. */\nexport function dueStep(p: PrefRow['data'], now: Date): number | null {\n if (p.reminders === false || !p.end_user || !p.last_active) return null;\n const idleH = (now.getTime() - Date.parse(p.last_active)) / 3_600_000;\n if (!Number.isFinite(idleH)) return null;\n const from = Math.max(0, Math.trunc(p.ladder_step ?? 0));\n let step: number | null = null;\n for (let r = from; r < LADDER.length; r++) if (idleH >= LADDER[r]!.afterHours) step = r;\n if (step === null) return null;\n if (step > 0 && p.last_reminded_at) {\n const sinceH = (now.getTime() - Date.parse(p.last_reminded_at)) / 3_600_000;\n const gapH = LADDER[step]!.afterHours - LADDER[step - 1]!.afterHours;\n if (Number.isFinite(sinceH) && sinceH < gapH) return null;\n }\n return step;\n}\n\n/** Is it inside the user's quiet hours [quiet_start, quiet_end) in THEIR zone?\n * The window may wrap midnight; equal or missing bounds mean none. An unknown\n * zone is evaluated in UTC. Exported for tests. */\nexport function inQuietHours(p: PrefRow['data'], now: Date): boolean {\n const start = p.quiet_start;\n const end = p.quiet_end;\n if (typeof start !== 'number' || typeof end !== 'number' || start === end) return false;\n const h = localHour(now, p.timezone);\n return start < end ? h >= start && h < end : h >= start || h < end;\n}\n\n/** One formatter per zone, kept for the isolate's life: building an Intl formatter is the\n * expensive part, and a tick can test hundreds of users in the same few zones\n * (CPU per invocation is capped \u2014 2,000 ms on a Free staging/development project; on a dev slot 50 ms unless the function declares limits.cpuMs, up to 2,000; both within a 500,000 CPU-ms daily budget). */\nconst ZONE_FMT = new Map<string, Intl.DateTimeFormat | null>();\nfunction zoneFormatter(tz: string): Intl.DateTimeFormat | null {\n if (!ZONE_FMT.has(tz)) {\n let f: Intl.DateTimeFormat | null = null;\n try { f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', hourCycle: 'h23' }); } catch { /* unknown zone */ }\n if (ZONE_FMT.size < 500) ZONE_FMT.set(tz, f);\n return f;\n }\n return ZONE_FMT.get(tz)!;\n}\n\nfunction localHour(now: Date, tz: string | undefined): number {\n try {\n const part = zoneFormatter(tz || 'UTC')?.formatToParts(now).find((x) => x.type === 'hour');\n const h = Number(part?.value);\n if (Number.isInteger(h)) return h % 24;\n } catch { /* unknown zone \u2192 UTC below */ }\n return now.getUTCHours();\n}\n\nasync function readState(base: string, H: Record<string, string>, key: string): Promise<StateRow | null | 'error'> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${base}/v1/cms/items/push_state?filter=${filter}&limit=1`, { headers: H });\n if (!res.ok) return 'error';\n return ((await res.json()) as { data?: { items?: StateRow[] } }).data?.items?.[0] ?? null;\n}\n\nasync function writeState(\n base: string, H: Record<string, string>, key: string, state: StateRow | null, cursor: string,\n): Promise<boolean> {\n const data = { key, cursor, updated_at: new Date().toISOString() };\n const res = state\n ? await fetch(`${base}/v1/cms/items/push_state/${state.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(state.version !== undefined ? { 'if-match': String(state.version) } : {}) },\n body: JSON.stringify({ data }),\n }).catch(() => null)\n : await fetch(`${base}/v1/cms/items/push_state`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n }).catch(() => null);\n return !!res && res.ok;\n}\n\n/** Locale match: exact ('pt-BR'), then the base language ('pt'), then default. */\nfunction pick(messages: Messages, locale: string | undefined): Copy {\n if (locale) {\n if (messages[locale]) return messages[locale]!;\n const bare = locale.split('-')[0]!;\n if (messages[bare]) return messages[bare]!;\n }\n return messages.default ?? {};\n}\n\nasync function patch(base: string, H: Record<string, string>, d: TokenRow, data: Record<string, unknown>) {\n await fetch(`${base}/v1/cms/items/push_tokens/${d.item_id}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data }),\n }).catch(() => null);\n}\nasync function del(base: string, H: Record<string, string>, d: TokenRow) {\n await fetch(`${base}/v1/cms/items/push_tokens/${d.item_id}`, { method: 'DELETE', headers: H }).catch(() => null);\n}\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n"
20270
20840
  }
20271
20841
  },
20272
20842
  {
@@ -21040,6 +21610,9 @@ function quoteIdent2(name) {
21040
21610
  if (!IDENT_RE2.test(name)) throw new Error(`unsafe identifier '${name}' \u2014 only [A-Za-z_][A-Za-z0-9_]* is readable`);
21041
21611
  return `"${name}"`;
21042
21612
  }
21613
+ function parseTimestampNoTz(text) {
21614
+ return /* @__PURE__ */ new Date(`${text.replace(" ", "T")}Z`);
21615
+ }
21043
21616
  function redactSourceUrl(url) {
21044
21617
  try {
21045
21618
  const u = new URL(url);
@@ -21292,6 +21865,10 @@ var GenericPgAdapter = class {
21292
21865
  // transaction-pooler (pgbouncer/supavisor) compatible
21293
21866
  onnotice: () => {
21294
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
+ },
21295
21872
  connection: {
21296
21873
  application_name: "vxil-migrate",
21297
21874
  // startup-parameter copies of the session SETs — they survive driver
@@ -21608,6 +22185,25 @@ var supabaseSql = {
21608
22185
  params: after !== null ? [after] : []
21609
22186
  };
21610
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
+ }),
21611
22207
  /** does this database carry the Storage schema? */
21612
22208
  storageProbe: () => ({
21613
22209
  text: `SELECT to_regclass('storage.buckets') IS NOT NULL AS present`,
@@ -21700,17 +22296,36 @@ var SupabaseAdapter = class extends GenericPgAdapter {
21700
22296
  }
21701
22297
  /** Credential pages (supabaseSql.credentialsPage). The rows hold bcrypt
21702
22298
  * hashes: never logged, never written anywhere but the import request. */
21703
- async *readCredentials(cursor) {
22299
+ async *readCredentials(cursor, opts = {}) {
21704
22300
  const source = await this.introspect();
21705
22301
  if (!source.authUsers?.present) throw new Error("auth.users not present on this source");
21706
22302
  const cols = source.authUsers.columns ?? [];
21707
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
+ }
21708
22312
  let after = cursor !== void 0 && cursor !== "" ? cursor : null;
21709
22313
  for (; ; ) {
21710
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
+ }
21711
22324
  const out = rows.map((r) => {
21712
22325
  const h = typeof r.encrypted_password === "string" && r.encrypted_password.length > 0 ? r.encrypted_password : null;
21713
- 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;
21714
22329
  });
21715
22330
  const next = rows.length === this.batchSize ? String(rows[rows.length - 1].id) : null;
21716
22331
  yield { rows: out, nextCursor: next };
@@ -22422,6 +23037,11 @@ async function runApply(apply, sel = "prod", opts = {}) {
22422
23037
  report.errors++;
22423
23038
  }
22424
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
+ }
22425
23045
  let cmsApi = api;
22426
23046
  if (apply) {
22427
23047
  const enabledAt = await enableCmsBeforeSchema({ api, cfg });
@@ -22453,7 +23073,6 @@ cms: enabled (v${enabledAt}) before its schema \u2014 the declared cms config fo
22453
23073
  compared.collections += Object.keys(cfg.cms.collections).length;
22454
23074
  });
22455
23075
  const { functions: _fnFeature, ...configFeatures } = cfg.features;
22456
- void _fnFeature;
22457
23076
  const serverApiState = {};
22458
23077
  await runSection("config", async () => {
22459
23078
  if (!Object.keys(configFeatures).length) return;
@@ -22470,6 +23089,8 @@ ${r.feature}:`);
22470
23089
  }
22471
23090
  console.log(`
22472
23091
  ${r.feature} (remote v${r.version}):`);
23092
+ const activeOrgWarn = activeOrgEnableWarning(r.feature, r.changes);
23093
+ if (activeOrgWarn) console.error(`vxil: warning \u2014 ${activeOrgWarn}`);
22473
23094
  const { kept, ignored } = partitionConfigChanges(r.feature, r.changes, ignore);
22474
23095
  console.log(formatDiff(kept));
22475
23096
  if (ignored.length) console.log(` (${ignored.length} ignored: ${ignored.map((c) => c.path).join(", ")})`);
@@ -22482,6 +23103,30 @@ ${r.feature} (remote v${r.version}):`);
22482
23103
  compared.features++;
22483
23104
  }
22484
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
+ });
22485
23130
  const strictApiState = hasFlag("strict-api-state");
22486
23131
  const declaredApiStateKeys = [];
22487
23132
  await runSection("api-state", async () => {
@@ -22530,7 +23175,8 @@ ${r.feature}.${r.datum} (declared in config):`);
22530
23175
  if (!apply) count(key);
22531
23176
  }
22532
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;
22533
- compared[r.datum] = (compared[r.datum] ?? 0) + n;
23178
+ const ck = r.datum === "collections" ? "searchCollections" : r.datum;
23179
+ compared[ck] = (compared[ck] ?? 0) + n;
22534
23180
  }
22535
23181
  });
22536
23182
  await runSection("functions", async () => {
@@ -24234,7 +24880,7 @@ ${formatSummary(state)}`);
24234
24880
  case "architect": {
24235
24881
  const description = positional(0).join(" ").trim();
24236
24882
  if (!description) fail('usage: vxil architect "<describe your app>" [--name <n>] [--out vxil.config.ts] [--brief <file.md>|--no-brief]');
24237
- const { api } = requireApi(selector());
24883
+ const { api, baseUrl } = requireApi(selector());
24238
24884
  const nameFlag = flag("name");
24239
24885
  const noBrief = hasFlag("no-brief");
24240
24886
  const briefOut = flag("brief");
@@ -24275,7 +24921,7 @@ ${plan.rationale}`);
24275
24921
  if (briefOut && !plan.brief) {
24276
24922
  fail(`--brief ${briefOut}: the server returned no brief \u2014 nothing written`);
24277
24923
  } else if (briefOut) {
24278
- writeFileSync6(briefOut, renderBriefMarkdown(plan.brief, plan.app ?? {}));
24924
+ writeFileSync6(briefOut, renderBriefMarkdown(plan.brief, plan.app ?? {}, { site: briefSiteFor(baseUrl) }));
24279
24925
  console.log(`\u2713 wrote ${briefOut}`);
24280
24926
  } else if (plan.brief) {
24281
24927
  console.log(" (run again with --brief brief.md to write the full brief as markdown)");
@@ -24344,6 +24990,16 @@ ${plan.rationale}`);
24344
24990
  if (pulled.note) pullNotes.push(pulled.note);
24345
24991
  }
24346
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
+ }
24347
25003
  if (features.includes("ai")) {
24348
25004
  const block = featBlocks.ai;
24349
25005
  delete block.templates;
@@ -24895,7 +25551,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
24895
25551
  architect "<describe your app>" \u2014 plain English in, a reviewed vxil.config.ts draft out
24896
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>]
24897
25553
  push production overrides: --allow-mock-in-prod \xB7 --allow-sandbox-in-prod \xB7 --allow-dev-origins-in-prod \xB7 --yes
24898
- 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)
24899
25555
  doctor [--dev|--target <name>] \u2014 + secrets referenced-vs-stored (advisory) + per-function served/published/local hash + keys with last-used (stored session)
24900
25556
  versions <feature> \xB7 rollback <feature> --to <v>
24901
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
@@ -25767,6 +26423,9 @@ function migrateUsage() {
25767
26423
  rehashed at each user's first sign-in) \xB7 [--oidc-issuer https://<ref>.supabase.co/auth/v1]
25768
26424
  link users for the installed-app session hand-over \xB7 [--file-links shared-link] stored
25769
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)
25770
26429
  verify reconcile source vs target counts + re-links + restate the residuals (exit 1 on deltas)
25771
26430
  payments --from-provider stripe|paddle|paypal|revenuecat (--customers <file.csv> | --all) [--dry-run]
25772
26431
  backfill SUBSCRIPTIONS through the server sync leg (the project's own provider keys; resumable via
@@ -25871,6 +26530,8 @@ ${migrateUsage()}`);
25871
26530
  resume: hasFlag("resume"),
25872
26531
  ...hasFlag("with-password-hashes") ? { withPasswordHashes: true } : {},
25873
26532
  ...oidcIssuer !== void 0 ? { oidcIssuer } : {},
26533
+ ...hasFlag("with-oauth-identities") ? { withOauthIdentities: true } : {},
26534
+ ...hasFlag("keep-timestamps") ? { keepTimestamps: true } : {},
25874
26535
  ...fileLinks !== void 0 ? { fileLinks } : {},
25875
26536
  ...only?.length ? { only } : {},
25876
26537
  ...hasFlag("assume-schema") ? { assumeSchema: true } : {},