@vxil/cli 0.10.0 → 0.11.1

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.js CHANGED
@@ -6,13 +6,13 @@ var __export = (target, all) => {
6
6
  };
7
7
 
8
8
  // bin/vxil.ts
9
- import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync5, existsSync as existsSync8, appendFileSync as appendFileSync2, readFileSync as readFileSync8, chmodSync as chmodSync2, openSync, closeSync, fstatSync, renameSync } from "node:fs";
9
+ import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync5, existsSync as existsSync8, appendFileSync as appendFileSync2, readFileSync as readFileSync8, chmodSync as chmodSync2, openSync, closeSync, fstatSync, renameSync, mkdtempSync, rmSync as rmSync2 } from "node:fs";
10
10
  import { spawnSync } from "node:child_process";
11
- import { resolve as resolve7, dirname as dirname5, basename as basename4 } from "node:path";
12
- import { createInterface } from "node:readline";
11
+ import { resolve as resolve7, dirname as dirname5, basename as basename4, join as join4 } from "node:path";
12
+ import { createInterface as createInterface2 } from "node:readline";
13
13
  import { watch } from "node:fs";
14
14
  import { createServer } from "node:http";
15
- import { homedir as homedir3, hostname } from "node:os";
15
+ import { homedir as homedir3, hostname, tmpdir } from "node:os";
16
16
 
17
17
  // src/lib.ts
18
18
  import { pathToFileURL, fileURLToPath } from "node:url";
@@ -3903,8 +3903,9 @@ var AiConfigSchema = Type.Object({
3903
3903
  // stamps on the latest version, and POSTs a new version ONLY when the content
3904
3904
  // differs — so a push is idempotent and versions stay monotonic per name.
3905
3905
  // Item shape mirrors ai-v1 core.ts TemplateBody exactly (`template` is the
3906
- // name). Stored templates the config does not declare are reported (there is
3907
- // no delete route — they are never removed). Bounded to 50 entries: the
3906
+ // name). Stored templates the config does not declare are reported and left
3907
+ // in place — RETIRED (soft: hidden from list + render, history kept) only
3908
+ // under --allow-destructive (cvskit F67, 2026-10-01). Bounded to 50 entries: the
3908
3909
  // manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
3909
3910
  templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES }))
3910
3911
  });
@@ -4009,7 +4010,18 @@ var PaymentsConfigSchema = Type.Object({
4009
4010
  // sandbox event is persisted as outcome 'rejected_environment' (200, never
4010
4011
  // folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
4011
4012
  // environments by signing secret / API base, so only RC carries this knob.
4012
- acceptSandbox: Type.Boolean({ default: false })
4013
+ acceptSandbox: Type.Boolean({ default: false }),
4014
+ // (2026-10-01 §4.13 A18) Store-review purchases on a PRODUCTION tenant:
4015
+ // the reviewer accounts' RevenueCat `app_user_id`s (≤ 20). A SANDBOX event
4016
+ // whose subject (and, for a TRANSFER, every source user) is listed here
4017
+ // folds — recorded `environment: 'sandbox'` on the delivery, the
4018
+ // subscription and the charge, so it stays out of revenue — while every
4019
+ // other sandbox event is still `rejected_environment`. The narrow,
4020
+ // production-safe alternative to `acceptSandbox` (which folds EVERY
4021
+ // TestFlight purchase and is refused by the CLI's production promotion
4022
+ // gate). No default on purpose (Value.Default would materialize it into
4023
+ // every RC manifest); absent = an empty list.
4024
+ sandboxUsers: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 256 }), { maxItems: 20 }))
4013
4025
  })),
4014
4026
  paypal: Type.Optional(Type.Object({
4015
4027
  clientIdRef: Type.String(),
@@ -4077,7 +4089,15 @@ var PaymentsConfigSchema = Type.Object({
4077
4089
  creditType: Type.String({ minLength: 1 }),
4078
4090
  amount: Type.Integer({ minimum: 1 }),
4079
4091
  // #128
4080
- period: Type.String()
4092
+ period: Type.String(),
4093
+ // (2026-10-01 §4.13 W9) 'add' (the reader's default, today's
4094
+ // behaviour) ADDS `amount` each period; 'reset' makes the period's
4095
+ // grant REPLACE what is left: the unspent available balance of
4096
+ // `creditType` is written off as one `expire` ledger row and `amount`
4097
+ // granted, so the user starts every period with exactly `amount`
4098
+ // (credits held by an in-flight job stay held). No schema default (it
4099
+ // would materialize into every manifest carrying grants).
4100
+ mode: Type.Optional(Type.Union([Type.Literal("add"), Type.Literal("reset")]))
4081
4101
  })))
4082
4102
  })),
4083
4103
  // provider price/plan id → tier. A real subscription webhook carries the
@@ -4209,7 +4229,11 @@ var FunctionsConfigSchema = Type.Object({
4209
4229
  // effective attempts = min(maxAttempts, jobs.retry.defaultMaxAttempts).
4210
4230
  retry: Type.Optional(Type.Object({
4211
4231
  maxAttempts: Type.Integer({ minimum: 1, maximum: FN_RETRY_MAX_ATTEMPTS })
4212
- }))
4232
+ })),
4233
+ // W7 (2026-10-01): cron only — 'skip' = a due tick fires nothing
4234
+ // while the previous tick's run is still open (queued / running /
4235
+ // retrying / waiting / delayed). Absent = 'allow' (every tick runs).
4236
+ overlap: Type.Optional(Type.Union([Type.Literal("allow"), Type.Literal("skip")]))
4213
4237
  }),
4214
4238
  { maxItems: 8 }
4215
4239
  )
@@ -4222,6 +4246,23 @@ var FunctionsConfigSchema = Type.Object({
4222
4246
  // names of tenant secrets injected at invoke time
4223
4247
  egressAllow: Type.Optional(Type.Array(Type.String())),
4224
4248
  // Outbound Worker allowlist hosts
4249
+ // R1 (2026-10-01): the runtime settings this function's script was
4250
+ // UPLOADED with — server-SET by the deploy pipeline (never authored),
4251
+ // read back by the rollback re-upload so a restored script runs under
4252
+ // the settings it was deployed and tested with, not today's. Absent =
4253
+ // the legacy settings (@vxil/types FUNCTIONS_RUNTIME_LEGACY). Unbounded
4254
+ // strings on purpose (the deploy writes them from the one constant).
4255
+ // cpuMs (F2, 2026-10-01): the per-invoke CPU limit the script was
4256
+ // uploaded with (limits.cpu_ms = min(declared limits.cpuMs, the tier's
4257
+ // cpuMsPerInvoke, FN_MAX_CPU_MS)) — server-set; the nightly plan pass
4258
+ // rewrites it after a tier change. Absent = the platform default.
4259
+ runtime: Type.Optional(
4260
+ Type.Object({
4261
+ compatibilityDate: Type.String(),
4262
+ compatibilityFlags: Type.Optional(Type.Array(Type.String())),
4263
+ cpuMs: Type.Optional(Type.Number())
4264
+ })
4265
+ ),
4225
4266
  // Per-function resource declarations. BOTH members are OPTIONAL (the bag
4226
4267
  // used to REQUIRE all three, a latent 422 the moment anything sent it —
4227
4268
  // nothing ever did, because the CLI never carried it) and BOTH are
@@ -4389,21 +4430,26 @@ function lowerTriggerBindings(trigger) {
4389
4430
  const n = r && typeof r === "object" ? r.maxAttempts : void 0;
4390
4431
  return typeof n === "number" && Number.isInteger(n) ? { retry: { maxAttempts: n } } : {};
4391
4432
  };
4392
- if (t === "cron") return [{ kind: "cron", ...str2("schedule") ? { schedule: str2("schedule") } : {}, ...retryOf() }];
4393
- if (t === "queue") return [{ kind: "queue", ...str2("source") ? { source: str2("source") } : {}, ...retryOf() }];
4394
- if (t === "webhook") return [{ kind: "webhook", ...str2("source") ? { source: str2("source") } : {}, ...retryOf() }];
4433
+ const overlapOf = () => {
4434
+ const o = trigger?.overlap;
4435
+ return o === void 0 || o === "allow" ? {} : { overlap: o };
4436
+ };
4437
+ if (t === "cron") return [{ kind: "cron", ...str2("schedule") ? { schedule: str2("schedule") } : {}, ...retryOf(), ...overlapOf() }];
4438
+ if (t === "queue") return [{ kind: "queue", ...str2("source") ? { source: str2("source") } : {}, ...retryOf(), ...overlapOf() }];
4439
+ if (t === "webhook") return [{ kind: "webhook", ...str2("source") ? { source: str2("source") } : {}, ...retryOf(), ...overlapOf() }];
4395
4440
  if (t === "cmsHook" || t === "cms-hook") {
4396
4441
  return [{
4397
4442
  kind: "cmsHook",
4398
4443
  ...str2("collection") ? { collection: str2("collection") } : {},
4399
4444
  ...str2("event") ? { event: str2("event") } : {},
4400
- ...retryOf()
4445
+ ...retryOf(),
4446
+ ...overlapOf()
4401
4447
  }];
4402
4448
  }
4403
4449
  if (t === "authHook" || t === "auth-hook") {
4404
- return [{ kind: "authHook", ...str2("event") ? { event: str2("event") } : {}, ...retryOf() }];
4450
+ return [{ kind: "authHook", ...str2("event") ? { event: str2("event") } : {}, ...retryOf(), ...overlapOf() }];
4405
4451
  }
4406
- return [{ kind: "http", ...str2("path") ? { path: str2("path") } : {}, ...retryOf() }];
4452
+ return [{ kind: "http", ...str2("path") ? { path: str2("path") } : {}, ...retryOf(), ...overlapOf() }];
4407
4453
  }
4408
4454
  function lowerAllTriggerBindings(def, name) {
4409
4455
  const declared = [def.trigger, ...def.triggers ?? []].filter((t) => Boolean(t));
@@ -4415,9 +4461,9 @@ function lowerAllTriggerBindings(def, name) {
4415
4461
  return bindings;
4416
4462
  }
4417
4463
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
4418
- var VXIL_CONFIG_PKG_VERSION = "0.6.0";
4419
- var VXIL_SDK_PKG_VERSION = "0.10.0";
4420
- var VXIL_CLI_PKG_VERSION = "0.10.0";
4464
+ var VXIL_CONFIG_PKG_VERSION = "0.7.0";
4465
+ var VXIL_SDK_PKG_VERSION = "0.11.1";
4466
+ var VXIL_CLI_PKG_VERSION = "0.11.1";
4421
4467
  function ensureScaffoldPackageJson(cwd, opts = {}) {
4422
4468
  const file = resolve(cwd, "package.json");
4423
4469
  const wanted = {
@@ -7054,10 +7100,50 @@ function scopesForFunctionAudience(aud, scopes) {
7054
7100
  }
7055
7101
  var TENANT_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
7056
7102
  var FUNCTION_SOURCE_MAX_BYTES = 512e3;
7103
+ var FN_PLATFORM_DEFAULT_CPU_MS = 3e4;
7104
+ function functionScriptCpuMs(declaredCpuMs, ceilingMs) {
7105
+ const d = typeof declaredCpuMs === "number" && Number.isFinite(declaredCpuMs) && declaredCpuMs >= 1 ? Math.floor(declaredCpuMs) : null;
7106
+ return d === null ? ceilingMs : Math.min(d, ceilingMs);
7107
+ }
7108
+ function recordedScriptCpuMs(raw) {
7109
+ const c = raw?.cpuMs;
7110
+ return isPositiveInt(c) ? c : FN_PLATFORM_DEFAULT_CPU_MS;
7111
+ }
7112
+ function planScriptCpuMs(runtime, declaredCpuMs, ceilingMs) {
7113
+ const recorded = runtime?.cpuMs;
7114
+ if (!isPositiveInt(recorded) && ceilingMs >= FN_PLATFORM_DEFAULT_CPU_MS) return FN_PLATFORM_DEFAULT_CPU_MS;
7115
+ return functionScriptCpuMs(declaredCpuMs, ceilingMs);
7116
+ }
7117
+ function isPositiveInt(v) {
7118
+ return typeof v === "number" && Number.isInteger(v) && v > 0;
7119
+ }
7057
7120
  var FUNCTIONS_RUNTIME = {
7058
7121
  compatibilityDate: "2025-09-01",
7059
7122
  compatibilityFlags: []
7060
7123
  };
7124
+ var FUNCTIONS_RUNTIME_LEGACY = {
7125
+ compatibilityDate: "2025-09-01",
7126
+ compatibilityFlags: []
7127
+ };
7128
+ function recordedFunctionsRuntime(raw) {
7129
+ const r = raw;
7130
+ if (r && typeof r === "object" && typeof r.compatibilityDate === "string" && /^\d{4}-\d{2}-\d{2}$/.test(r.compatibilityDate) && (r.compatibilityFlags === void 0 || Array.isArray(r.compatibilityFlags) && r.compatibilityFlags.every((f) => typeof f === "string"))) {
7131
+ return {
7132
+ compatibilityDate: r.compatibilityDate,
7133
+ compatibilityFlags: [...r.compatibilityFlags ?? []],
7134
+ ...isPositiveInt(r.cpuMs) ? { cpuMs: r.cpuMs } : {}
7135
+ };
7136
+ }
7137
+ return { compatibilityDate: FUNCTIONS_RUNTIME_LEGACY.compatibilityDate, compatibilityFlags: [...FUNCTIONS_RUNTIME_LEGACY.compatibilityFlags] };
7138
+ }
7139
+ function sameFunctionsRuntime(a, b2) {
7140
+ return a.compatibilityDate === b2.compatibilityDate && a.compatibilityFlags.length === b2.compatibilityFlags.length && a.compatibilityFlags.every((f, i) => f === b2.compatibilityFlags[i]);
7141
+ }
7142
+ function functionsRuntimeHashSuffix(settings) {
7143
+ if (sameFunctionsRuntime(settings, FUNCTIONS_RUNTIME_LEGACY)) return "";
7144
+ return `
7145
+ /* vxil-runtime ${settings.compatibilityDate} ${settings.compatibilityFlags.join(",")} */`;
7146
+ }
7061
7147
 
7062
7148
  // ../runtime/src/hash.ts
7063
7149
  async function sha256Hex(input) {
@@ -7465,7 +7551,7 @@ async function readOnlyPlan(api, block, allowDestructive) {
7465
7551
  s.created.push(...plan.create);
7466
7552
  s.updated.push(...plan.update);
7467
7553
  s.unchanged.push(...plan.unchanged);
7468
- s.undeclared.push(...plan.undeclared);
7554
+ destructive([], plan.undeclared);
7469
7555
  }
7470
7556
  return out;
7471
7557
  }
@@ -7586,8 +7672,43 @@ import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync
7586
7672
  import { homedir } from "node:os";
7587
7673
  import { dirname as dirname2, resolve as resolve2, relative, isAbsolute, sep } from "node:path";
7588
7674
  import { createHash } from "node:crypto";
7589
- function sourceSha12(source) {
7590
- return createHash("sha256").update(source, "utf8").digest("hex").slice(0, 12);
7675
+ function describeBinding(b2) {
7676
+ const parts = [b2.kind];
7677
+ if (b2.kind === "cron") parts.push(b2.schedule ? `'${b2.schedule}'` : "(no schedule)");
7678
+ else if (b2.kind === "webhook" || b2.kind === "queue") parts.push(b2.source ? `source ${b2.source}` : "(every event)");
7679
+ else if (b2.kind === "cmsHook") parts.push(`${b2.collection ?? "(every collection)"} on ${b2.event ?? "beforeWrite"}`);
7680
+ else if (b2.kind === "authHook") parts.push(b2.event ?? "(every auth event)");
7681
+ else if (b2.kind === "http" && b2.path) parts.push(b2.path);
7682
+ if (b2.retry) parts.push(`retry:${b2.retry.maxAttempts}`);
7683
+ if (b2.overlap) parts.push(`overlap:${b2.overlap}`);
7684
+ return parts.join(" ");
7685
+ }
7686
+ function diffBindings(remote, local) {
7687
+ const r = remote.map(describeBinding);
7688
+ const l = local.map(describeBinding);
7689
+ const removed = r.filter((x) => !l.includes(x));
7690
+ const added = l.filter((x) => !r.includes(x));
7691
+ return removed.length || added.length ? { removed, added } : null;
7692
+ }
7693
+ function hookReconcileOf(data2) {
7694
+ if (!data2) return null;
7695
+ const n = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
7696
+ const cms = data2.cms_hook_subscriptions;
7697
+ const wh = data2.webhook_subscriptions;
7698
+ const hr = data2.hook_reconcile;
7699
+ if (!cms && !wh && !hr) return null;
7700
+ const failed = Math.max(n(hr?.failed), n(cms?.failed) + n(wh?.failed));
7701
+ if (failed === 0) return { failed };
7702
+ const listed = Array.isArray(data2.warnings) ? data2.warnings.find((w) => typeof w === "string" && /trigger subscription/i.test(w)) : void 0;
7703
+ const warning = typeof data2.hook_reconcile_warning === "string" ? data2.hook_reconcile_warning : listed;
7704
+ return { failed, ...warning ? { warning } : {} };
7705
+ }
7706
+ function hookReconcileRemedy(fnName) {
7707
+ return fnName ? `re-run the reconcile with \`vxil functions deploy ${fnName} --force\` or \`vxil push --server\` (a plain re-push sends no deploy for an unchanged function, so it would not retry it)` : "re-run the reconcile with `vxil push --server` (a plain re-push sends no deploy, so it would not retry it)";
7708
+ }
7709
+ function sourceSha12(source, runtime) {
7710
+ const suffix = runtime ? functionsRuntimeHashSuffix(runtime) : "";
7711
+ return createHash("sha256").update(source + suffix, "utf8").digest("hex").slice(0, 12);
7591
7712
  }
7592
7713
  function escapesRoot(root, path) {
7593
7714
  const rel = relative(root, path);
@@ -7751,7 +7872,11 @@ async function readRemote(api) {
7751
7872
  const res = await api("GET", "/v1/functions");
7752
7873
  const map2 = /* @__PURE__ */ new Map();
7753
7874
  const unavailable = readUnavailable("GET /v1/functions", res);
7754
- if (res.status !== 200) return { map: map2, unavailable };
7875
+ const wire = res.status === 200 ? res.body.data?.runtime : void 0;
7876
+ const deployRuntime = wire ? recordedFunctionsRuntime(wire) : FUNCTIONS_RUNTIME;
7877
+ const ceilingWire = res.status === 200 ? res.body.data?.cpuMsCeiling : void 0;
7878
+ const cpuMsCeiling = typeof ceilingWire === "number" && Number.isInteger(ceilingWire) && ceilingWire > 0 ? ceilingWire : null;
7879
+ if (res.status !== 200) return { map: map2, unavailable, deployRuntime, cpuMsCeiling };
7755
7880
  const fns = Array.isArray(res.body.data?.functions) ? res.body.data.functions : [];
7756
7881
  for (const f of fns) {
7757
7882
  map2.set(f.name, {
@@ -7761,10 +7886,11 @@ async function readRemote(api) {
7761
7886
  egressAllow: f.egressAllow ?? [],
7762
7887
  bindings: f.bindings ?? [],
7763
7888
  signature: f.signature ?? null,
7764
- limits: f.limits ?? null
7889
+ limits: f.limits ?? null,
7890
+ runtime: recordedFunctionsRuntime(f.runtime)
7765
7891
  });
7766
7892
  }
7767
- return { map: map2, unavailable: null };
7893
+ return { map: map2, unavailable: null, deployRuntime, cpuMsCeiling };
7768
7894
  }
7769
7895
  function sameSecretSet(a, b2) {
7770
7896
  return a.length === b2.length && a.every((x) => b2.includes(x));
@@ -7777,7 +7903,7 @@ function sameStringSet2(a, b2) {
7777
7903
  function clampFunctionScopes(scopes) {
7778
7904
  return (scopes ?? []).map(String).filter((s) => s && !DENY_FUNCTION_SCOPES.has(s));
7779
7905
  }
7780
- async function planFunctions({ api, functions, cwd, apply, onNote }) {
7906
+ async function planFunctions({ api, functions, cwd, apply, onNote, remoteOnly = "ignore", force = false }) {
7781
7907
  const read = await readRemote(api);
7782
7908
  if (read.unavailable) {
7783
7909
  if (apply) {
@@ -7798,13 +7924,20 @@ async function planFunctions({ api, functions, cwd, apply, onNote }) {
7798
7924
  const secrets = normalizeSecretRefs(def.secrets);
7799
7925
  const limits = normalizeFnLimits(def.limits);
7800
7926
  const remoteFn = remote.get(name);
7801
- const sha = sourceSha12(source);
7927
+ const sha = sourceSha12(source, read.deployRuntime);
7802
7928
  const retryN = bindings.find((b2) => b2.retry)?.retry?.maxAttempts;
7803
7929
  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" : ""}]`;
7804
- if (remoteFn !== void 0 && remoteFn.scriptRef.endsWith(`-${sha}`) && sameSecretSet(remoteFn.secrets, secrets) && 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)) {
7805
- return { change: { name, kind: "unchanged", detail: label }, applied: 0, cmsHooks: null, webhooks: null };
7930
+ 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) && !force) {
7931
+ return { change: { name, kind: "unchanged", detail: label }, applied: 0, cmsHooks: null, webhooks: null, hook: null };
7806
7932
  }
7807
- const change = { name, kind: remoteFn === void 0 ? "deploy-new" : "deploy-update", detail: label };
7933
+ const bindingChanges = remoteFn !== void 0 ? diffBindings(remoteFn.bindings, bindings) : null;
7934
+ const change = {
7935
+ name,
7936
+ kind: remoteFn === void 0 ? "deploy-new" : "deploy-update",
7937
+ detail: label,
7938
+ bindings: bindings.map(describeBinding),
7939
+ ...bindingChanges ? { bindingChanges } : {}
7940
+ };
7808
7941
  if (apply) {
7809
7942
  const body = { source, trigger: triggerKind, secrets };
7810
7943
  if (bindings.length) body.bindings = bindings;
@@ -7821,12 +7954,35 @@ async function planFunctions({ api, functions, cwd, apply, onNote }) {
7821
7954
  const cmsHooks = data2?.cms_hook_subscriptions ?? null;
7822
7955
  const webhooks = data2?.webhook_subscriptions ?? null;
7823
7956
  const warnings2 = (data2?.warnings ?? []).map((message) => ({ fn: name, message: String(message) }));
7824
- return { change, applied: 1, cmsHooks, webhooks, warnings: warnings2 };
7957
+ return { change, applied: 1, cmsHooks, webhooks, warnings: warnings2, hook: hookReconcileOf(data2) };
7825
7958
  }
7826
- return { change, applied: 0, cmsHooks: null, webhooks: null, warnings: [] };
7959
+ return { change, applied: 0, cmsHooks: null, webhooks: null, warnings: [], hook: null };
7827
7960
  });
7828
- const changes = results.map((r) => r.change);
7961
+ const remoteOnlyNames = remoteOnly === "ignore" ? [] : [...remote.keys()].filter((n) => !(n in functions)).sort();
7962
+ const remoteOnlyKept = [];
7963
+ let lastDeleteHook = null;
7964
+ const remoteOnlyChanges = [];
7965
+ for (const name of remoteOnlyNames) {
7966
+ const r = remote.get(name);
7967
+ const bl = r.bindings.map(describeBinding);
7968
+ const what = `${name} [deployed, not in vxil.config${bl.length ? ` \xB7 ${bl.join(" + ")}` : ""}${r.scopes.length ? ` \xB7 ${r.scopes.join(",")}` : ""}]`;
7969
+ if (remoteOnly !== "delete") {
7970
+ remoteOnlyKept.push(name);
7971
+ remoteOnlyChanges.push({ name, kind: "remote-only", detail: `${what} \u2014 kept (it still runs with its old code, bindings and scopes); \`vxil push --allow-destructive\` deletes it` });
7972
+ continue;
7973
+ }
7974
+ if (!apply) {
7975
+ remoteOnlyChanges.push({ name, kind: "delete", detail: `${what} \u2014 will be DELETED by this push (--allow-destructive)` });
7976
+ continue;
7977
+ }
7978
+ const del = await deleteRemoteFunction(api, name);
7979
+ if (del.hook) lastDeleteHook = del.hook;
7980
+ remoteOnlyChanges.push({ name, kind: "delete", detail: `${what} \u2014 ${del.gone ? "already gone" : "deleted (its triggers are unwired; the script is reaped by the nightly GC)"}` });
7981
+ }
7982
+ const changes = [...results.map((r) => r.change), ...remoteOnlyChanges];
7829
7983
  const applied = results.reduce((s, r) => s + r.applied, 0);
7984
+ const deployHooks = results.map((r) => r.hook).filter((h) => h !== null);
7985
+ const hookReconcile = lastDeleteHook ?? deployHooks[deployHooks.length - 1] ?? null;
7830
7986
  const sum = (pick) => results.reduce((acc, r) => {
7831
7987
  const v = pick(r);
7832
7988
  if (!v) return acc;
@@ -7835,12 +7991,51 @@ async function planFunctions({ api, functions, cwd, apply, onNote }) {
7835
7991
  const cmsHookSubscriptions = sum((r) => r.cmsHooks);
7836
7992
  const webhookSubscriptions = sum((r) => r.webhooks);
7837
7993
  const warnings = results.flatMap((r) => r.warnings ?? []);
7838
- return { changes, applied, cmsHookSubscriptions, webhookSubscriptions, ...warnings.length ? { warnings } : {} };
7994
+ return {
7995
+ changes,
7996
+ applied,
7997
+ cmsHookSubscriptions,
7998
+ webhookSubscriptions,
7999
+ ...warnings.length ? { warnings } : {},
8000
+ hookReconcile,
8001
+ ...remoteOnlyKept.length ? { remoteOnlyKept } : {}
8002
+ };
8003
+ }
8004
+ async function deleteRemoteFunction(api, name, version) {
8005
+ const res = await api(
8006
+ "DELETE",
8007
+ `/v1/functions/${encodeURIComponent(name)}`,
8008
+ void 0,
8009
+ version !== void 0 ? { "if-match": String(version) } : {}
8010
+ );
8011
+ if (res.status === 404) return { gone: true, hook: null };
8012
+ if (res.status !== 200) {
8013
+ const e = res.body.error ?? {};
8014
+ throw new Error(`functions: delete ${name} failed: ${e.code ?? res.status} ${e.message ?? ""} ${e.hint ?? ""}`.trim());
8015
+ }
8016
+ const data2 = res.body.data;
8017
+ return { gone: data2?.deleted === false, hook: hookReconcileOf(data2) };
7839
8018
  }
7840
8019
  function formatFnChanges(changes) {
7841
8020
  const actionable = changes.filter((c) => c.kind !== "unchanged");
7842
8021
  if (!actionable.length) return " (no function changes)";
7843
- return actionable.map((c) => ` ${c.kind === "deploy-new" ? "+" : "~"} ${c.detail}`).join("\n");
8022
+ const mark = (k) => k === "deploy-new" ? "+" : k === "deploy-update" ? "~" : "-";
8023
+ const out = [];
8024
+ for (const c of actionable) {
8025
+ out.push(` ${mark(c.kind)} ${c.detail}`);
8026
+ if (c.bindingChanges) {
8027
+ for (const b2 of c.bindingChanges.removed) out.push(` - binding ${b2}`);
8028
+ for (const b2 of c.bindingChanges.added) out.push(` + binding ${b2}`);
8029
+ if ([...c.bindingChanges.removed, ...c.bindingChanges.added].some((b2) => /^(webhook|cmsHook|authHook)\b/.test(b2))) {
8030
+ out.push(" (its trigger subscription is updated on deploy and keeps the events it has not delivered yet)");
8031
+ }
8032
+ } else if (c.bindings?.length && c.kind === "deploy-new") {
8033
+ for (const b2 of c.bindings) out.push(` binding ${b2}`);
8034
+ } else if (c.bindings?.length) {
8035
+ out.push(` bindings unchanged: ${c.bindings.join(" \xB7 ")}`);
8036
+ }
8037
+ }
8038
+ return out.join("\n");
7844
8039
  }
7845
8040
  async function probeFunctionsEntitlement(api, name) {
7846
8041
  const res = await api("POST", `/v1/functions/${encodeURIComponent(name)}`, {});
@@ -7873,7 +8068,7 @@ function formatFunctionsPreflight(e, opts) {
7873
8068
  }
7874
8069
  const plan = /'([a-z]+)' plan/.exec(e.message ?? "")?.[1];
7875
8070
  const planLabel = plan ? `the ${plan[0].toUpperCase()}${plan.slice(1)} plan` : "a paid plan";
7876
- return `functions need ${planLabel}${e.message ? ` (${e.message})` : ""} \u2014 ${n} would be refused with 402 tier_required. Upgrade in the dashboard (${billingPageFor(opts.tenantId, opts.baseUrl)}) or with \`vxil billing upgrade --tier ${plan ?? "<tier>"}\` (it needs your dashboard session \u2014 \`vxil login\` \u2014 not the project key)` + (opts.verb === "push" ? ", or run `vxil push --skip-functions` to push the cms schema and feature config without them" : "") + ".";
8071
+ return `functions need ${planLabel}${e.message ? ` (${e.message})` : ""} \u2014 ${n} would be refused with 402 tier_required. Upgrade in the dashboard (${billingPageFor(opts.tenantId, opts.baseUrl)}) or with \`vxil billing upgrade --tier ${plan ?? "<tier>"}\` (it needs your dashboard session \u2014 \`vxil login\` \u2014 not the project key); or, if this project is not production, mark it staging or development (\`vxil projects workload <slug> staging\`) \u2014 Free deploys functions there, within the Free limits` + (opts.verb === "push" ? "; or run `vxil push --skip-functions` to push the cms schema and feature config without them" : "") + ".";
7877
8072
  }
7878
8073
  function formatLogLines(lines) {
7879
8074
  const out = [];
@@ -8499,6 +8694,7 @@ var API_CHEATSHEET = [
8499
8694
  { feature: "jobs", what: "enqueue a job", cmd: `vxil api POST /v1/jobs/enqueue '{"job_name":"resize","target_url":"https://\u2026","payload":{}}'` },
8500
8695
  { feature: "jobs", what: "wake runs waiting on an event", cmd: `vxil api POST /v1/jobs/events '{"event":"order.paid","payload":{}}'` },
8501
8696
  { feature: "jobs", what: "replay / cancel a run", cmd: "vxil api POST /v1/jobs/runs/<run_id>/replay \xB7 vxil api POST /v1/jobs/runs/<run_id>/cancel" },
8697
+ { feature: "jobs", what: "list schedules: held (fired, not started 2+ intervals), skipped_fires, overlap \u2014 fn-cron:* rows are function crons", cmd: "vxil api GET /v1/jobs/schedules" },
8502
8698
  { feature: "jobs", what: "create / pause / resume / delete a schedule", cmd: `vxil api POST /v1/jobs/schedules '{"job_name":"digest","target_url":"https://\u2026","cron":"0 8 * * *"}' \xB7 POST /v1/jobs/schedules/<schedule_id>/pause \xB7 POST \u2026/resume \xB7 vxil api DELETE /v1/jobs/schedules/<schedule_id>` },
8503
8699
  { feature: "jobs", what: "flow rules (rate + parallelism per endpoint or job)", cmd: `vxil api POST /v1/jobs/flow-rules '{"match_kind":"job_name","match_value":"resize","max_parallel":2}' \xB7 vxil api DELETE /v1/jobs/flow-rules/<rule_id>` },
8504
8700
  // files
@@ -9002,7 +9198,7 @@ function promotionRefusals(cfg, allowed) {
9002
9198
  out.push({
9003
9199
  kind: "sandbox",
9004
9200
  flag: PROMOTION_OVERRIDE_FLAGS.sandbox,
9005
- message: `sandbox provider flags [${sandboxes.join(", ")}] would ship to a live money path (a sandbox key signs with the wrong secret; RevenueCat folds test purchases as outcome rejected_environment). Set them false, or override with --${PROMOTION_OVERRIDE_FLAGS.sandbox}.`
9201
+ message: `sandbox provider flags [${sandboxes.join(", ")}] would ship to a live money path (a sandbox key signs with the wrong secret; revenuecat.acceptSandbox folds EVERY TestFlight / test-track purchase into live entitlements \u2014 for store review, list the reviewer app_user_ids in revenuecat.sandboxUsers instead, which a production push allows). Set them false, or override with --${PROMOTION_OVERRIDE_FLAGS.sandbox}.`
9006
9202
  });
9007
9203
  }
9008
9204
  const devOrigins = devRedirectOrigins(redirectOrigins(cfg));
@@ -9088,7 +9284,12 @@ async function linkViaDeviceFlow(dash, slug, io) {
9088
9284
  throw new Error("link timed out waiting for browser approval \u2014 run `vxil link <slug>` again");
9089
9285
  }
9090
9286
  function reconcileLinkTenant(flagTenant, verified) {
9091
- if (verified === "(unknown)") return { tenantId: flagTenant ?? "(unknown)" };
9287
+ if (verified === "(unknown)") {
9288
+ if (flagTenant === void 0) return { tenantId: "(unknown)" };
9289
+ return {
9290
+ error: `cannot verify --tenant ${flagTenant} against the key \u2014 the server named no tenant for it (GET /v1/features meta.tenant_id absent); nothing was linked. Drop --tenant to record the binding unverified (\`vxil doctor\` then warns that the key tenant is not verified).`
9291
+ };
9292
+ }
9092
9293
  if (flagTenant !== void 0 && flagTenant !== verified) {
9093
9294
  return {
9094
9295
  error: `--tenant ${flagTenant} does not match the key's own tenant ${verified} (GET /v1/features meta.tenant_id) \u2014 nothing was linked. Check which key you passed, or drop --tenant to record the key's tenant.`
@@ -9271,7 +9472,362 @@ async function acceptInviteViaSession(dash, cookie, token) {
9271
9472
  return { tenant_id: d.tenant_id, role: d.role ?? "" };
9272
9473
  }
9273
9474
 
9475
+ // src/credentialExport.ts
9476
+ async function exportCredentialsViaSession(dash, cookie, tenantId, onPage) {
9477
+ let after = null;
9478
+ let n = 0;
9479
+ let total = null;
9480
+ for (let pages = 0; pages < 1e4; pages++) {
9481
+ const q = after !== null ? `?after=${encodeURIComponent(after)}` : "";
9482
+ const r = await dash("GET", `/dashboard/tenants/${encodeURIComponent(tenantId)}/credentials/export${q}`, void 0, cookie);
9483
+ if (r.status === 401) return { unauthorized: true };
9484
+ if (r.status !== 200) throw new Error(`the credential export was refused (${errText(r)})`);
9485
+ const p = data(r);
9486
+ if (pages === 0 && typeof p.total === "number") total = p.total;
9487
+ const users = p.users ?? [];
9488
+ await onPage(users);
9489
+ n += users.length;
9490
+ after = p.next_after ?? null;
9491
+ if (after === null) return { users: n, total };
9492
+ }
9493
+ throw new Error("the credential export did not finish within 10,000 pages");
9494
+ }
9495
+ function credentialsHeader(slug, tenantId, now = /* @__PURE__ */ new Date()) {
9496
+ return JSON.stringify({
9497
+ vxil_credentials: 1,
9498
+ project: slug,
9499
+ source_tenant: tenantId,
9500
+ exported_at: now.toISOString(),
9501
+ format: {
9502
+ scrypt: "scrypt$<N>$<r>$<p>$<salt base64>$<32-byte key base64> (N=16384, r=8, p=1)",
9503
+ bcrypt: "an imported bcrypt string its user has not signed in with on vxil yet"
9504
+ },
9505
+ import: "POST /v1/auth/users/import accepts both formats as-is. User ids are unique across projects in an environment, so while the source project still holds these users every row answers id_taken: delete the source project first, then import into the new one."
9506
+ });
9507
+ }
9508
+ function credentialLine(c) {
9509
+ return JSON.stringify({ t: "credential", r: c });
9510
+ }
9511
+ var CREDENTIAL_EXPORT_NOTE = [
9512
+ "This exports the stored password hash of every user of this project who has a password.",
9513
+ "A hash lets anyone who holds the file test password guesses offline, at their own pace \u2014 and many",
9514
+ "people reuse passwords. Keep the file encrypted, on as few machines as possible, and delete it once",
9515
+ "it is imported. The export is recorded in the project's audit log (auth.credentials.exported) and",
9516
+ "can run once an hour. Only a project owner can run it."
9517
+ ].join("\n");
9518
+
9519
+ // src/exportPostgres.ts
9520
+ import { createReadStream } from "node:fs";
9521
+ import { createInterface } from "node:readline";
9522
+ var PG_TYPE = {
9523
+ string: "text",
9524
+ text: "text",
9525
+ int: "bigint",
9526
+ float: "double precision",
9527
+ bool: "boolean",
9528
+ datetime: "timestamptz",
9529
+ json: "jsonb",
9530
+ relation: "text",
9531
+ // the related item's _id (see the column comment)
9532
+ file: "text"
9533
+ // the object_id in <schema>.files
9534
+ };
9535
+ var DATA_TYPE = {
9536
+ text: "text",
9537
+ bigint: "bigint",
9538
+ "double precision": "double precision",
9539
+ boolean: "boolean",
9540
+ timestamptz: "timestamp with time zone",
9541
+ jsonb: "jsonb"
9542
+ };
9543
+ var IDENT_RE = /^[a-z_][a-z0-9_]{0,62}$/;
9544
+ function quoteIdent(name) {
9545
+ if (!IDENT_RE.test(name)) throw new Error(`not a safe SQL identifier: '${name}'`);
9546
+ return `"${name}"`;
9547
+ }
9548
+ var META_COLS = [
9549
+ { name: "_id", type: "text" },
9550
+ { name: "_status", type: "text" },
9551
+ { name: "_created_at", type: "timestamptz" },
9552
+ { name: "_updated_at", type: "timestamptz" },
9553
+ { name: "_published_at", type: "timestamptz" },
9554
+ { name: "_data", type: "jsonb" }
9555
+ ];
9556
+ function collectionTables(fields) {
9557
+ const by = /* @__PURE__ */ new Map();
9558
+ for (const f of fields) {
9559
+ if (!IDENT_RE.test(f.collection) || !IDENT_RE.test(f.field)) continue;
9560
+ by.set(f.collection, [...by.get(f.collection) ?? [], f]);
9561
+ }
9562
+ return [...by.keys()].sort().map((c) => ({
9563
+ table: `cms_${c}`.slice(0, 63),
9564
+ pk: "_id",
9565
+ columns: [
9566
+ ...META_COLS,
9567
+ ...by.get(c).sort((a, b2) => a.field.localeCompare(b2.field)).map((f) => ({
9568
+ name: f.field,
9569
+ type: PG_TYPE[f.type] ?? "jsonb",
9570
+ field: f,
9571
+ ...f.type === "relation" && f.relation_to ? { comment: `relation \u2192 cms_${f.relation_to}._id` } : {},
9572
+ ...f.type === "file" ? { comment: "file \u2192 files.object_id" } : {}
9573
+ }))
9574
+ ]
9575
+ }));
9576
+ }
9577
+ var USERS_TABLE = {
9578
+ table: "users",
9579
+ pk: "id",
9580
+ columns: [
9581
+ { name: "id", type: "text" },
9582
+ { name: "email", type: "text" },
9583
+ { name: "display_name", type: "text" },
9584
+ { name: "avatar_url", type: "text" },
9585
+ { name: "attributes", type: "jsonb" }
9586
+ ]
9587
+ };
9588
+ var CREDENTIAL_COLUMNS = [
9589
+ { name: "password_hash", type: "text" },
9590
+ { name: "password_algorithm", type: "text" },
9591
+ { name: "email_verified", type: "boolean" }
9592
+ ];
9593
+ var FILES_TABLE = {
9594
+ table: "files",
9595
+ pk: "object_id",
9596
+ columns: [
9597
+ { name: "object_id", type: "text" },
9598
+ { name: "end_user_id", type: "text" },
9599
+ { name: "filename", type: "text" },
9600
+ { name: "content_type", type: "text" },
9601
+ { name: "size_bytes", type: "bigint" },
9602
+ { name: "checksum_sha256", type: "text" },
9603
+ { name: "status", type: "text" },
9604
+ { name: "created_at", type: "timestamptz" },
9605
+ { name: "uploaded_at", type: "timestamptz" },
9606
+ { name: "download_url", type: "text", comment: "a presigned URL \u2014 dead after download_expires_at" },
9607
+ { name: "download_expires_at", type: "timestamptz" },
9608
+ { name: "_row", type: "jsonb" }
9609
+ ]
9610
+ };
9611
+ function coerce(type, v) {
9612
+ if (v === void 0 || v === null) return { value: null, mismatch: false };
9613
+ switch (type) {
9614
+ case "text":
9615
+ return typeof v === "string" ? { value: v, mismatch: false } : typeof v === "number" || typeof v === "boolean" ? { value: String(v), mismatch: false } : { value: null, mismatch: true };
9616
+ case "bigint":
9617
+ return typeof v === "number" && Number.isSafeInteger(v) ? { value: v, mismatch: false } : typeof v === "string" && /^-?\d{1,15}$/.test(v) ? { value: Number(v), mismatch: false } : { value: null, mismatch: true };
9618
+ case "double precision":
9619
+ return typeof v === "number" && Number.isFinite(v) ? { value: v, mismatch: false } : typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v)) ? { value: Number(v), mismatch: false } : { value: null, mismatch: true };
9620
+ case "boolean":
9621
+ return typeof v === "boolean" ? { value: v, mismatch: false } : { value: null, mismatch: true };
9622
+ case "timestamptz":
9623
+ return typeof v === "string" && Number.isFinite(Date.parse(v)) ? { value: new Date(Date.parse(v)).toISOString(), mismatch: false } : { value: null, mismatch: true };
9624
+ case "jsonb":
9625
+ return { value: v, mismatch: false };
9626
+ default:
9627
+ return { value: null, mismatch: true };
9628
+ }
9629
+ }
9630
+ async function* exportLines(path) {
9631
+ const rl = createInterface({ input: createReadStream(path, "utf8"), crlfDelay: Infinity });
9632
+ for await (const line of rl) {
9633
+ if (!line.trim()) continue;
9634
+ let o;
9635
+ try {
9636
+ o = JSON.parse(line);
9637
+ } catch {
9638
+ throw new Error(`${path}: a line is not JSON \u2014 was the export cut short?`);
9639
+ }
9640
+ const x = o;
9641
+ if (typeof x.t === "string" && x.r && typeof x.r === "object") {
9642
+ yield { t: x.t, r: x.r, ...x.download && typeof x.download === "object" ? { download: x.download } : {} };
9643
+ }
9644
+ }
9645
+ }
9646
+ var TableLoader = class {
9647
+ constructor(sql, schema, plan, runAt, upsertCols) {
9648
+ this.sql = sql;
9649
+ this.schema = schema;
9650
+ this.plan = plan;
9651
+ this.runAt = runAt;
9652
+ this.upsertCols = upsertCols;
9653
+ }
9654
+ sql;
9655
+ schema;
9656
+ plan;
9657
+ runAt;
9658
+ upsertCols;
9659
+ rows = [];
9660
+ count = 0;
9661
+ mismatches = {};
9662
+ /** CREATE IF NOT EXISTS + ADD COLUMN IF NOT EXISTS; refuses a column whose
9663
+ * existing type differs (never ALTERs your data's type behind your back). */
9664
+ async ensure() {
9665
+ const s = quoteIdent(this.schema);
9666
+ const t = `${s}.${quoteIdent(this.plan.table)}`;
9667
+ await this.sql.unsafe(`CREATE SCHEMA IF NOT EXISTS ${s}`);
9668
+ await this.sql.unsafe(`CREATE TABLE IF NOT EXISTS ${t} (${quoteIdent(this.plan.pk)} text PRIMARY KEY, "_exported_at" timestamptz NOT NULL)`);
9669
+ const have = new Map((await this.sql`
9670
+ SELECT column_name, data_type FROM information_schema.columns
9671
+ WHERE table_schema = ${this.schema} AND table_name = ${this.plan.table}`).map((r) => [r.column_name, r.data_type]));
9672
+ for (const c of this.plan.columns) {
9673
+ if (c.name === this.plan.pk) continue;
9674
+ const cur = have.get(c.name);
9675
+ if (cur !== void 0 && cur !== DATA_TYPE[c.type]) {
9676
+ throw new Error(`${this.schema}.${this.plan.table}.${c.name} is ${cur} in your database but the field is now ${c.type} \u2014 rename or drop that column (or export into a fresh --pg-schema); vxil never rewrites a column type for you`);
9677
+ }
9678
+ if (cur === void 0) await this.sql.unsafe(`ALTER TABLE ${t} ADD COLUMN IF NOT EXISTS ${quoteIdent(c.name)} ${c.type}`);
9679
+ if (c.comment) await this.sql.unsafe(`COMMENT ON COLUMN ${t}.${quoteIdent(c.name)} IS '${c.comment.replace(/'/g, "''")}'`);
9680
+ }
9681
+ }
9682
+ async add(row2) {
9683
+ this.rows.push(row2);
9684
+ this.count++;
9685
+ if (this.rows.length >= 500) await this.flush();
9686
+ }
9687
+ /** Mark a column mismatch (the value went to _data / _row only). */
9688
+ miss(col) {
9689
+ this.mismatches[col] = (this.mismatches[col] ?? 0) + 1;
9690
+ }
9691
+ async flush() {
9692
+ if (this.rows.length === 0) return;
9693
+ const cols = this.upsertCols ?? this.plan.columns.map((c) => c.name);
9694
+ const typeOf = new Map(this.plan.columns.map((c) => [c.name, c.type]));
9695
+ const t = `${quoteIdent(this.schema)}.${quoteIdent(this.plan.table)}`;
9696
+ const list = [...cols, "_exported_at"].map(quoteIdent).join(", ");
9697
+ const rec = [...cols.map((c) => `${quoteIdent(c)} ${typeOf.get(c) ?? "text"}`), '"_exported_at" timestamptz'].join(", ");
9698
+ const set = [...cols.filter((c) => c !== this.plan.pk), "_exported_at"].map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`).join(", ");
9699
+ const batch = this.rows.map((r) => ({ ...r, _exported_at: this.runAt }));
9700
+ this.rows = [];
9701
+ await this.sql.unsafe(
9702
+ `INSERT INTO ${t} (${list}) SELECT ${list} FROM jsonb_to_recordset($1::text::jsonb) AS x(${rec}) ON CONFLICT (${quoteIdent(this.plan.pk)}) DO UPDATE SET ${set}`,
9703
+ [JSON.stringify(batch)]
9704
+ );
9705
+ }
9706
+ /** After a COMPLETE export: drop what this run did not see. */
9707
+ async prune() {
9708
+ const t = `${quoteIdent(this.schema)}.${quoteIdent(this.plan.table)}`;
9709
+ const r = await this.sql.unsafe(`DELETE FROM ${t} WHERE "_exported_at" < $1::timestamptz`, [this.runAt]);
9710
+ return r.count;
9711
+ }
9712
+ };
9713
+ async function clearCredentialColumns(sql, schema) {
9714
+ const [has] = await sql`
9715
+ SELECT count(*)::int AS n FROM information_schema.columns
9716
+ WHERE table_schema = ${schema} AND table_name = 'users' AND column_name IN ('password_hash', 'password_algorithm')`;
9717
+ if ((has?.n ?? 0) < 2) return 0;
9718
+ const r = await sql.unsafe(
9719
+ `UPDATE ${quoteIdent(schema)}."users" SET "password_hash" = NULL, "password_algorithm" = NULL WHERE "password_hash" IS NOT NULL`
9720
+ );
9721
+ return r.count;
9722
+ }
9723
+ async function loadIntoPostgres(sql, schema, inputs, log, runAt = (/* @__PURE__ */ new Date()).toISOString()) {
9724
+ quoteIdent(schema);
9725
+ const report = { tables: [], skipped: [] };
9726
+ const done = async (l, complete) => {
9727
+ await l.flush();
9728
+ const pruned = complete ? await l.prune() : null;
9729
+ report.tables.push({ table: `${schema}.${l.plan.table}`, rows: l.count, pruned, mismatches: l.mismatches });
9730
+ log(` \u2713 ${schema}.${l.plan.table}: ${l.count} row(s)${pruned !== null ? `, ${pruned} removed (gone on vxil)` : " (export incomplete \u2014 nothing removed)"}` + (Object.keys(l.mismatches).length ? ` \xB7 values that did not fit their column (kept in _data): ${Object.entries(l.mismatches).map(([c, n]) => `${c}=${n}`).join(" ")}` : ""));
9731
+ };
9732
+ if (inputs.users) {
9733
+ const plan = inputs.credentials ? { ...USERS_TABLE, columns: [...USERS_TABLE.columns, ...CREDENTIAL_COLUMNS] } : USERS_TABLE;
9734
+ const l = new TableLoader(sql, schema, plan, runAt, USERS_TABLE.columns.map((c) => c.name));
9735
+ await l.ensure();
9736
+ for await (const { t, r } of exportLines(inputs.users)) {
9737
+ if (t !== "users") continue;
9738
+ await l.add({ id: r.id, email: r.email ?? null, display_name: r.display_name ?? null, avatar_url: r.avatar_url ?? null, attributes: r.attributes ?? {} });
9739
+ }
9740
+ await done(l, inputs.complete.users);
9741
+ const cleared = await clearCredentialColumns(sql, schema);
9742
+ if (cleared > 0 && !inputs.credentials) {
9743
+ log(` \u2713 ${schema}.users: ${cleared} password hash(es) cleared \u2014 this run did not include --include-password-hashes, and the mirror never keeps an older one`);
9744
+ }
9745
+ if (inputs.credentials) {
9746
+ const c = new TableLoader(sql, schema, plan, runAt, ["id", "email", "password_hash", "password_algorithm", "email_verified"]);
9747
+ for await (const { t, r } of exportLines(inputs.credentials)) {
9748
+ if (t !== "credential") continue;
9749
+ await c.add({ id: r.id, email: r.email ?? null, password_hash: r.password_hash, password_algorithm: r.algorithm, email_verified: r.email_verified === true });
9750
+ }
9751
+ await c.flush();
9752
+ log(` \u2713 ${schema}.users: ${c.count} password hash(es) loaded \u2014 treat this database as holding credentials`);
9753
+ }
9754
+ }
9755
+ if (inputs.cms) {
9756
+ const fields = [];
9757
+ for await (const { t, r } of exportLines(inputs.cms)) {
9758
+ if (t === "fields") fields.push({ collection: String(r.collection), field: String(r.field), type: String(r.type), relation_to: r.relation_to ?? null });
9759
+ }
9760
+ const loaders = /* @__PURE__ */ new Map();
9761
+ for (const plan of collectionTables(fields)) {
9762
+ const l = new TableLoader(sql, schema, plan, runAt);
9763
+ await l.ensure();
9764
+ loaders.set(plan.table.slice("cms_".length), l);
9765
+ }
9766
+ for await (const { t, r } of exportLines(inputs.cms)) {
9767
+ if (t !== "items" || r.deleted_at) continue;
9768
+ const l = loaders.get(String(r.collection));
9769
+ if (!l) {
9770
+ report.skipped.push(`cms item ${String(r.item_id)}: collection '${String(r.collection)}' has no fields in the export`);
9771
+ continue;
9772
+ }
9773
+ const data2 = r.data && typeof r.data === "object" ? r.data : {};
9774
+ const row2 = {
9775
+ _id: r.item_id,
9776
+ _status: r.status ?? null,
9777
+ _created_at: r.created_at ?? null,
9778
+ _updated_at: r.updated_at ?? null,
9779
+ _published_at: r.published_at ?? null,
9780
+ _data: data2
9781
+ };
9782
+ for (const c of l.plan.columns) {
9783
+ if (!c.field) continue;
9784
+ const v = coerce(c.type, data2[c.field.field]);
9785
+ if (v.mismatch) l.miss(c.name);
9786
+ row2[c.name] = v.value;
9787
+ }
9788
+ await l.add(row2);
9789
+ }
9790
+ for (const l of loaders.values()) await done(l, inputs.complete.cms);
9791
+ }
9792
+ if (inputs.files) {
9793
+ const l = new TableLoader(sql, schema, FILES_TABLE, runAt);
9794
+ await l.ensure();
9795
+ for await (const { t, r, download } of exportLines(inputs.files)) {
9796
+ if (t !== "objects") continue;
9797
+ const row2 = { _row: r, download_url: download?.url ?? null, download_expires_at: download?.expires_at ?? null };
9798
+ for (const c of FILES_TABLE.columns) {
9799
+ if (c.name in row2) continue;
9800
+ const v = coerce(c.type, r[c.name]);
9801
+ if (v.mismatch) l.miss(c.name);
9802
+ row2[c.name] = v.value;
9803
+ }
9804
+ await l.add(row2);
9805
+ }
9806
+ await done(l, inputs.complete.files);
9807
+ }
9808
+ return report;
9809
+ }
9810
+
9811
+ // src/migrate/adapters/targetPg.ts
9812
+ function openTargetDatabase(url) {
9813
+ if (!/^postgres(ql)?:\/\//i.test(url)) {
9814
+ throw new Error("--to-postgres needs a postgres:// (or postgresql://) connection string");
9815
+ }
9816
+ return src_default(url, {
9817
+ max: 1,
9818
+ prepare: false,
9819
+ // transaction-pooler compatible
9820
+ onnotice: () => {
9821
+ },
9822
+ connection: { application_name: "vxil-export" }
9823
+ });
9824
+ }
9825
+
9274
9826
  // src/projects.ts
9827
+ var PROJECT_WORKLOADS = ["production", "staging", "development"];
9828
+ function isWorkload(v) {
9829
+ return typeof v === "string" && PROJECT_WORKLOADS.includes(v);
9830
+ }
9275
9831
  async function listProjectsViaSession(dash, cookie) {
9276
9832
  const r = await dash("GET", "/dashboard/tenants", void 0, cookie);
9277
9833
  if (r.status === 401) return { unauthorized: true };
@@ -9283,18 +9839,26 @@ function renderProjects(tenants, bound) {
9283
9839
  return tenants.map((t) => {
9284
9840
  const slot = bound.get(t.slug);
9285
9841
  const kind = t.kind === "dev" ? ` \xB7 dev${t.expires_at ? `, expires ${t.expires_at}` : ""}` : "";
9286
- return `${slot ? `[${slot}]`.padEnd(14) : "".padEnd(14)}${t.slug} ${t.display_name} ${t.tier}${t.role ? ` \xB7 ${t.role}` : ""}${kind}`;
9842
+ const workload = t.kind !== "dev" && t.workload ? ` \xB7 ${t.workload}` : "";
9843
+ return `${slot ? `[${slot}]`.padEnd(14) : "".padEnd(14)}${t.slug} ${t.display_name} ${t.tier}${workload}${t.role ? ` \xB7 ${t.role}` : ""}${kind}`;
9287
9844
  });
9288
9845
  }
9289
- function parseProjectCreate(slug, name) {
9846
+ function parseProjectCreate(slug, name, workload) {
9290
9847
  const s = (slug ?? "").trim();
9291
- if (!s) return { error: "usage: vxil projects create <slug> [--name <display name>] [--json]" };
9848
+ if (!s) return { error: "usage: vxil projects create <slug> [--name <display name>] [--workload production|staging|development] [--json]" };
9292
9849
  if (!TENANT_SLUG_RE.test(s)) return { error: `'${s}' is not a valid slug \u2014 lowercase letters, digits and hyphens (${TENANT_SLUG_RE.source})` };
9850
+ if (workload !== void 0 && !isWorkload(workload)) {
9851
+ return { error: `--workload must be one of ${PROJECT_WORKLOADS.join(", ")}` };
9852
+ }
9293
9853
  const display = (name ?? "").trim() || s;
9294
- return { body: { slug: s, display_name: display } };
9854
+ return { body: { slug: s, display_name: display, ...workload !== void 0 ? { workload } : {} } };
9295
9855
  }
9296
9856
  async function createProjectViaSession(dash, cookie, body) {
9297
- const r = await dash("POST", "/dashboard/tenants", { slug: body.slug, display_name: body.display_name }, cookie);
9857
+ const r = await dash("POST", "/dashboard/tenants", {
9858
+ slug: body.slug,
9859
+ display_name: body.display_name,
9860
+ ...body.workload ? { workload: body.workload } : {}
9861
+ }, cookie);
9298
9862
  if (r.status === 401) return { unauthorized: true };
9299
9863
  if (r.status !== 201 && r.status !== 200) throw new Error(`could not create '${body.slug}' (${errText(r)})`);
9300
9864
  const d = data(r);
@@ -9306,10 +9870,32 @@ async function createProjectViaSession(dash, cookie, body) {
9306
9870
  display_name: d.display_name ?? body.display_name,
9307
9871
  tier: d.tier ?? "free",
9308
9872
  kind: d.kind ?? null,
9309
- expires_at: d.expires_at ?? null
9873
+ expires_at: d.expires_at ?? null,
9874
+ ...d.workload ? { workload: d.workload } : {}
9310
9875
  }
9311
9876
  };
9312
9877
  }
9878
+ function parseWorkloadArg(v) {
9879
+ if (!v) return { error: `usage: vxil projects workload <slug> ${PROJECT_WORKLOADS.join("|")} [--json]` };
9880
+ return isWorkload(v) ? { workload: v } : { error: `workload must be one of ${PROJECT_WORKLOADS.join(", ")} (got '${v}')` };
9881
+ }
9882
+ async function setProjectWorkloadViaSession(dash, cookie, tenantId, workload) {
9883
+ const r = await dash("PATCH", `/dashboard/tenants/${tenantId}`, { workload }, cookie);
9884
+ if (r.status === 401) return { unauthorized: true };
9885
+ if (r.status !== 200) throw new Error(`could not change the workload (${errText(r)})`);
9886
+ return { change: data(r) };
9887
+ }
9888
+ function renderWorkloadChange(slug, c) {
9889
+ if (!c.changed) return { stdout: `\u2713 '${slug}' is already ${c.workload}`, stderr: [] };
9890
+ const err = [];
9891
+ const w = c.functions_wall;
9892
+ if (w && (w.walled ?? 0) > 0) {
9893
+ err.push("its functions are paused: a production project needs a plan that includes functions for production (Developer and up) \u2014 upgrade (`vxil billing upgrade`), or set the workload back");
9894
+ }
9895
+ if (w && (w.unwalled ?? 0) > 0) err.push("its paused functions are running again");
9896
+ if (w && ((w.failed ?? 0) > 0 || w.error)) err.push("the functions wall could not be applied right now \u2014 the nightly pass retries it");
9897
+ return { stdout: `\u2713 '${slug}' is now ${c.workload} (was ${c.from ?? "?"})`, stderr: err };
9898
+ }
9313
9899
  async function deleteProjectViaSession(dash, cookie, tenantId, slug) {
9314
9900
  const r = await dash("DELETE", `/dashboard/tenants/${tenantId}`, { confirm: slug }, cookie);
9315
9901
  if (r.status === 401) return { unauthorized: true };
@@ -9989,7 +10575,9 @@ function buildEnvelope(opts) {
9989
10575
  vxil_base: DEV_VXIL_BASE,
9990
10576
  scoped_jwts: scopedJwts,
9991
10577
  secrets: opts.secrets ?? {},
9992
- payload: opts.payload
10578
+ payload: opts.payload,
10579
+ // a deployed cron tick names the slot it was due for (2026-10-01)
10580
+ ...trigger === "cron" ? { scheduled_for: opts.scheduledFor ?? new Date(Math.floor(Date.now() / 6e4) * 6e4).toISOString() } : {}
9993
10581
  };
9994
10582
  }
9995
10583
  function audForPath(pathname) {
@@ -10286,6 +10874,42 @@ function stripApiVersionsBlock(src) {
10286
10874
  if (j < 0) return src;
10287
10875
  return src.slice(0, i) + src.slice(j + API_VERSIONS_BLOCK_TAIL.length);
10288
10876
  }
10877
+ function parseApiVersionsBlock(src) {
10878
+ const i = src.indexOf(API_VERSIONS_BLOCK_HEAD);
10879
+ if (i < 0) return null;
10880
+ const start = i + API_VERSIONS_BLOCK_HEAD.length;
10881
+ const j = src.indexOf(API_VERSIONS_BLOCK_TAIL, start);
10882
+ if (j < 0) return null;
10883
+ const out = {};
10884
+ for (const line of src.slice(start, j).split("\n")) {
10885
+ if (!line.trim()) continue;
10886
+ const m = /^\s+(?:"((?:[^"\\]|\\.)*)"|([A-Za-z_$][\w$]*)): \[(.*)\],$/.exec(line);
10887
+ if (!m) return null;
10888
+ const key = m[1] !== void 0 ? JSON.parse(`"${m[1]}"`) : m[2];
10889
+ let majors;
10890
+ try {
10891
+ majors = JSON.parse(`[${m[3]}]`);
10892
+ } catch {
10893
+ return null;
10894
+ }
10895
+ if (!Array.isArray(majors) || !majors.every((x) => typeof x === "string")) return null;
10896
+ out[key] = majors;
10897
+ }
10898
+ return out;
10899
+ }
10900
+ function apiMajorDrift(pinned, live) {
10901
+ const rows = [];
10902
+ const feats = [.../* @__PURE__ */ new Set([...Object.keys(pinned), ...Object.keys(live)])].sort(compareCodePoints);
10903
+ const known = /* @__PURE__ */ new Set(["v1", ...Object.values(pinned).flat()]);
10904
+ for (const f of feats) {
10905
+ const a = [...pinned[f] ?? []].sort();
10906
+ const b2 = [...live[f] ?? []].sort();
10907
+ if (!(f in live)) continue;
10908
+ if (!(f in pinned) && b2.every((m) => known.has(m))) continue;
10909
+ if (a.join("|") !== b2.join("|")) rows.push({ feature: f, pinned: a, live: b2 });
10910
+ }
10911
+ return rows;
10912
+ }
10289
10913
  function canonicalCollections(collections) {
10290
10914
  return [...collections].sort((a, b2) => compareCodePoints(a.collection, b2.collection)).map((c) => ({ ...c, fields: [...c.fields].sort((a, b2) => compareCodePoints(a.field, b2.field)) }));
10291
10915
  }
@@ -10331,6 +10955,7 @@ function checkGeneratedTypes(existing, generated, opts) {
10331
10955
  let unpinnedNotice = false;
10332
10956
  if (opts.offline) {
10333
10957
  have = stripApiVersionsBlock(have);
10958
+ want = stripApiVersionsBlock(want);
10334
10959
  } else if (existing && hasApiVersionsBlock(generated) && !hasApiVersionsBlock(existing)) {
10335
10960
  unpinnedNotice = true;
10336
10961
  want = stripApiVersionsBlock(want);
@@ -10686,7 +11311,7 @@ var TOOLS = [
10686
11311
  {
10687
11312
  name: "jobs_list_runs",
10688
11313
  feature: "jobs",
10689
- description: "List job runs (state machine: queued\u2192running\u2192succeeded | retrying\u2192dead | cancelled; delayed = scheduled future delivery, waiting = suspended on an event).",
11314
+ description: "List job runs (state machine: queued\u2192running\u2192succeeded | retrying\u2192dead | cancelled; delayed = scheduled future delivery, waiting = suspended on an event). A platform webhook delivery run (job_name webhooks.deliver) carries webhook_event + audit_id (the event id in the audit log, = the audit_id your handler receives) \u2014 the event a dead delivery lost; its payload stays redacted.",
10690
11315
  inputSchema: {
10691
11316
  type: "object",
10692
11317
  properties: {
@@ -10787,7 +11412,7 @@ var TOOLS = [
10787
11412
  {
10788
11413
  name: "jobs_create_schedule",
10789
11414
  feature: "jobs",
10790
- description: "Create a recurring (5-field cron, UTC) or one-shot (run_at ISO) schedule that enqueues the job on the minute tick. Exactly one of cron/run_at.",
11415
+ description: "Create a recurring (5-field cron, UTC) or one-shot (run_at ISO) schedule that enqueues the job on the minute tick. Exactly one of cron/run_at. overlap 'skip' (cron only): no new run while the previous one is still open \u2014 the slot is counted, never caught up.",
10791
11416
  inputSchema: {
10792
11417
  type: "object",
10793
11418
  properties: {
@@ -10795,13 +11420,22 @@ var TOOLS = [
10795
11420
  target_url: { type: "string" },
10796
11421
  payload: { type: "object", additionalProperties: true },
10797
11422
  cron: { type: "string", description: "e.g. '0 */6 * * *' (UTC)" },
10798
- run_at: { type: "string", description: "future ISO timestamp" }
11423
+ run_at: { type: "string", description: "future ISO timestamp" },
11424
+ overlap: { type: "string", enum: ["allow", "skip"], default: "allow", description: "cron only: 'skip' fires nothing while an earlier run of this schedule is queued / running / retrying / waiting / delayed" }
10799
11425
  },
10800
11426
  required: ["job_name", "target_url"]
10801
11427
  },
10802
11428
  method: "POST",
10803
11429
  path: "/v1/jobs/schedules"
10804
11430
  },
11431
+ {
11432
+ name: "jobs_list_schedules",
11433
+ feature: "jobs",
11434
+ description: "List the tenant's schedules (incl. the fn-cron:* function ticks): cron, state, next/last run, overlap, skipped_fires, and `held` \u2014 non-null when the schedule fired on time but its oldest run has not started for 2+ intervals (held downstream).",
11435
+ inputSchema: { type: "object", properties: {} },
11436
+ method: "GET",
11437
+ path: "/v1/jobs/schedules"
11438
+ },
10805
11439
  {
10806
11440
  name: "jobs_get_signing_secret",
10807
11441
  feature: "jobs",
@@ -10924,6 +11558,19 @@ var TOOLS = [
10924
11558
  method: "GET",
10925
11559
  path: (a) => `/v1/files/${encodeURIComponent(String(a.object_id))}/download-url`
10926
11560
  },
11561
+ {
11562
+ name: "files_get_download_urls",
11563
+ feature: "files",
11564
+ description: "Mint presigned GET URLs for up to 100 objects in one call (comma-separated object_ids). Unknown, deleted or still-uploading ids are listed under errors[] instead of failing the batch.",
11565
+ inputSchema: {
11566
+ type: "object",
11567
+ properties: { object_ids: { type: "string", description: "Comma-separated object ids (at most 100)." } },
11568
+ required: ["object_ids"]
11569
+ },
11570
+ method: "GET",
11571
+ path: "/v1/files/download-urls",
11572
+ queryArgs: ["object_ids"]
11573
+ },
10927
11574
  {
10928
11575
  name: "files_list",
10929
11576
  feature: "files",
@@ -11045,7 +11692,7 @@ var TOOLS = [
11045
11692
  {
11046
11693
  name: "cms_query_items",
11047
11694
  feature: "cms",
11048
- description: 'Query content items with the bounded DSL. filter is a JSON object (\u22648 terms): {"price":{"$gte":10},"status":"published"}; ops $eq $ne $gt $gte $lt $lte $in $contains $startsWith (substring/prefix, LIKE-escaped) $arrayContains/$anyOf (array fields: all-of / any-of). sort: "-field" (slot-indexed fields + created_at/updated_at/published_at). $expand inlines relation/file fields. count=true returns {count} instead of a page (filter only \u2014 no sort/limit/$expand).',
11695
+ description: 'Query content items with the bounded DSL. filter is a JSON object (\u22648 terms): {"price":{"$gte":10},"status":"published"}; ops $eq $ne $gt $gte $lt $lte $in $contains $startsWith (substring/prefix, LIKE-escaped) $arrayContains/$anyOf (array fields: all-of / any-of). sort: "-field" (slot-indexed fields + created_at/updated_at/published_at). Paging: pass the returned next_cursor back as cursor WITH THE SAME filter and sort (works for the default order and for any slot/timestamp sort; not for a dotted join sort). $expand inlines relation/file fields. count=true returns {count} instead of a page (filter only \u2014 no sort/limit/cursor/$expand).',
11049
11696
  inputSchema: {
11050
11697
  type: "object",
11051
11698
  properties: {
@@ -11053,6 +11700,7 @@ var TOOLS = [
11053
11700
  filter: { type: "string", description: "JSON filter object as a string" },
11054
11701
  sort: { type: "string" },
11055
11702
  limit: { type: "number", default: 25 },
11703
+ cursor: { type: "string", description: "next_cursor from the previous page (same filter + sort)" },
11056
11704
  $expand: {
11057
11705
  type: "string",
11058
11706
  description: "comma-separated relation/file field names to inline (bounded depth; see cms_list_collections for relation_to). Cannot be combined with count=true."
@@ -11063,7 +11711,7 @@ var TOOLS = [
11063
11711
  },
11064
11712
  method: "GET",
11065
11713
  path: (a) => `/v1/cms/items/${encodeURIComponent(String(a.collection))}`,
11066
- queryArgs: ["filter", "sort", "limit", "$expand", "count"]
11714
+ queryArgs: ["filter", "sort", "limit", "cursor", "$expand", "count"]
11067
11715
  },
11068
11716
  {
11069
11717
  name: "cms_inc_item",
@@ -11501,6 +12149,20 @@ var TOOLS = [
11501
12149
  method: "DELETE",
11502
12150
  path: (a) => `/v1/webhooks/subscriptions/${encodeURIComponent(String(a.sub_id))}`
11503
12151
  },
12152
+ {
12153
+ name: "functions_delete",
12154
+ feature: "functions",
12155
+ description: "Remove ONE deployed function from the project: a new functions config version without it \u2014 invokes answer 404 and its crons and event triggers are unbound. The deployed code is kept until the nightly cleanup, so a functions config rollback restores it exactly. 404 function_not_found when the name is not deployed. Read the deployed names from the vxil://functions/catalog resource first. Needs features:write.",
12156
+ inputSchema: {
12157
+ type: "object",
12158
+ properties: {
12159
+ name: { type: "string", description: "The deployed function name (lowercase, e.g. on-payment)." }
12160
+ },
12161
+ required: ["name"]
12162
+ },
12163
+ method: "DELETE",
12164
+ path: (a) => `/v1/functions/${encodeURIComponent(String(a.name))}`
12165
+ },
11504
12166
  {
11505
12167
  name: "webhooks_test_subscription",
11506
12168
  feature: "webhooks",
@@ -11710,6 +12372,21 @@ var TOOLS = [
11710
12372
  method: "POST",
11711
12373
  path: "/v1/ai/generate"
11712
12374
  },
12375
+ {
12376
+ name: "ai_generation_get",
12377
+ feature: "ai",
12378
+ description: "Read a generation back: pass generation_id for one (status pending|processing|completed|failed, usage, run_id, and for a job-lane generation the settled answer in result.text, kept 30 days), or correlation_id to find yours by your own handle (newest first, at most 20; each with result:null \u2014 call again with that generation_id for its text) \u2014 the recovery read after a timed-out or lost job answer. result_state says stored | purged | not_recorded (sync and stream generations return their answer directly and record none).",
12379
+ inputSchema: {
12380
+ type: "object",
12381
+ properties: {
12382
+ generation_id: { type: "string", description: "The generation_id from the generate answer (use this OR correlation_id)." },
12383
+ correlation_id: { type: "string", minLength: 1, maxLength: 128, description: "The correlation_id you sent on ai_generate / the REST generate call." }
12384
+ }
12385
+ },
12386
+ method: "GET",
12387
+ path: (a) => a.generation_id ? `/v1/ai/generations/${encodeURIComponent(String(a.generation_id))}` : "/v1/ai/generations",
12388
+ queryArgs: ["correlation_id"]
12389
+ },
11713
12390
  {
11714
12391
  name: "ai_classify",
11715
12392
  feature: "ai",
@@ -11770,6 +12447,18 @@ var TOOLS = [
11770
12447
  method: "POST",
11771
12448
  path: "/v1/ai/judge"
11772
12449
  },
12450
+ {
12451
+ name: "ai_template_retire",
12452
+ feature: "ai",
12453
+ description: "Retire a stored prompt template (soft): every live version of the name leaves the template list and can no longer be rendered by ai_generate / ai_classify / ai_judge (404 template_not_found, by name or by version); the stored versions stay for the generations that used them, and storing the name again starts a new live version. 404 template_not_found when no live version exists. Needs ai:write. Prefer declaring templates in vxil.config \u2014 `vxil push --allow-destructive` retires the ones the config dropped.",
12454
+ inputSchema: {
12455
+ type: "object",
12456
+ properties: { name: { type: "string", pattern: "^[a-zA-Z0-9_.\\-]{1,128}$", description: "The template name." } },
12457
+ required: ["name"]
12458
+ },
12459
+ method: "DELETE",
12460
+ path: (a) => `/v1/ai/templates/${encodeURIComponent(String(a.name))}`
12461
+ },
11773
12462
  {
11774
12463
  name: "rag_answer",
11775
12464
  feature: "rag",
@@ -11849,18 +12538,25 @@ var TOOLS = [
11849
12538
  {
11850
12539
  name: "payments_consume",
11851
12540
  feature: "payments",
11852
- description: "Debit credits from an end-user \u2014 idempotent (idempotency_key REQUIRED; the SAME key always returns the SAME result, never double-charges) and balance-guarded (never oversells; returns 402 INSUFFICIENT_CREDITS when available can't cover amount). Pass job_id to make the debit a PROVISIONAL hold tied to a background job: a linked jobs run that terminally fails auto-refunds the hold, a success settles it. amount must be >= 1. Needs payments:write.",
12541
+ description: "Debit credits from an end-user \u2014 idempotent (idempotency_key REQUIRED; the SAME key always returns the SAME result, never double-charges) and balance-guarded (never oversells; returns 402 INSUFFICIENT_CREDITS when available can't cover amount). Name ONE credit_type, or an ordered credit_types list (e.g. ['free','subscription','topup']): the WHOLE amount comes from the FIRST type whose available balance covers it \u2014 never split across types \u2014 and the answer's credit_type names the one debited. Pass job_id to make the debit a PROVISIONAL hold tied to a background job: a linked jobs run that terminally fails auto-refunds the hold, a success settles it. amount must be >= 1. Needs payments:write.",
11853
12542
  inputSchema: {
11854
12543
  type: "object",
11855
12544
  properties: {
11856
12545
  user_id: { type: "string", description: "Opaque end_user_id to debit." },
11857
- credit_type: { type: "string", description: "Credit type to debit, e.g. 'scans'." },
12546
+ credit_type: { type: "string", description: "Credit type to debit, e.g. 'scans'. Give this OR credit_types." },
12547
+ credit_types: {
12548
+ type: "array",
12549
+ items: { type: "string" },
12550
+ minItems: 1,
12551
+ maxItems: 8,
12552
+ description: "Ordered credit types (1-8, distinct) \u2014 the first one whose available balance covers the whole amount is debited. Give this OR credit_type."
12553
+ },
11858
12554
  amount: { type: "number", minimum: 1, description: "Credits to consume (>= 1)." },
11859
12555
  idempotency_key: { type: "string", description: "Unique key for this debit; reusing it replays the first result (no double-charge)." },
11860
12556
  job_id: { type: "string", description: "Optional jobs run_id to link: makes the debit a provisional hold that auto-reverses if the job terminally fails." },
11861
12557
  reason: { type: "string", description: "Optional human-readable reason recorded on the ledger row." }
11862
12558
  },
11863
- required: ["user_id", "credit_type", "amount", "idempotency_key"]
12559
+ required: ["user_id", "amount", "idempotency_key"]
11864
12560
  },
11865
12561
  method: "POST",
11866
12562
  path: "/v1/payments/credits/consume",
@@ -11888,6 +12584,23 @@ var TOOLS = [
11888
12584
  path: "/v1/payments/webhook-events",
11889
12585
  queryArgs: ["provider", "event_type", "outcome", "environment", "provider_charge_id", "provider_evt_id", "since", "cursor", "limit"]
11890
12586
  },
12587
+ {
12588
+ name: "payments_list_refunds",
12589
+ feature: "payments",
12590
+ description: "List the refunds of ONE charge (charge_id) or read one refund (refund_id) \u2014 one of the two is required \u2014 newest first: refund_id, amount_cents, currency, status (pending until the provider settles it, e.g. a Paddle adjustment awaiting approval; succeeded; failed), provider_refund_id, reason, source (api = issued through POST /v1/payments/refunds, webhook = recorded from the provider's own dashboard) and created_at. Use it to tie a refund_id from a refund call, its 502/422 answer or a payments.refund.failed / payments.charge.refunded event back to the money, and to see whether a refund has landed before ending access. Read-only \u2014 needs payments:read. A thin-client key sees only its own charges' refunds; an unknown parameter is a 422.",
12591
+ inputSchema: {
12592
+ type: "object",
12593
+ properties: {
12594
+ charge_id: { type: "string", maxLength: 200, description: "The vxil charge id (chg_\u2026) whose refunds to list." },
12595
+ refund_id: { type: "string", maxLength: 200, description: "Read one refund by its id (ref_\u2026)." },
12596
+ status: { type: "string", enum: ["pending", "succeeded", "failed"] },
12597
+ limit: { type: "number", default: 50, description: "Page size, clamped 1..100." }
12598
+ }
12599
+ },
12600
+ method: "GET",
12601
+ path: "/v1/payments/refunds",
12602
+ queryArgs: ["charge_id", "refund_id", "status", "limit"]
12603
+ },
11891
12604
  {
11892
12605
  name: "mcp_get_signing_secret",
11893
12606
  description: 'Per-tenant secret your endpoints use to verify the X-Vxil-Mcp-Signature header on custom MCP tool calls (config mcp.customTools). Same recipe as jobs callbacks: t=<unix>,v1=<hmac-sha256-hex over "<toolName>.<tenantId>.<t>.<sha256hex(body)>">, 300s replay window. Needs features:read.',
@@ -12229,6 +12942,15 @@ function markObjectsComplete(b2) {
12229
12942
  function truncationNotes(b2) {
12230
12943
  return Array.isArray(b2.skipped) ? b2.skipped.filter((s) => /: truncated at \d+ rows/.test(s)) : [];
12231
12944
  }
12945
+ var EXPORT_EXIT_INCOMPLETE = 4;
12946
+ function incompleteTablesMessage(feature, tables, json2) {
12947
+ const list = tables.map((t) => `'${t}'`).join(", ");
12948
+ return `vxil: export ${feature} is INCOMPLETE \u2014 ${list} ${tables.length === 1 ? "was" : "were"} cut at the server row cap` + (json2 ? " (the JSON bundle is capped; `--ndjson` resumes past the cap)" : " and the server gave no resume cursor (an older server, or a table with no primary key)") + ` \u2014 the output holds every row up to the cut \u2014 exit ${EXPORT_EXIT_INCOMPLETE}`;
12949
+ }
12950
+ function worseExportExit(current, code) {
12951
+ const n = typeof current === "number" ? current : Number(current ?? 0) || 0;
12952
+ return Math.max(n, code);
12953
+ }
12232
12954
 
12233
12955
  // src/drift.ts
12234
12956
  function isPlainObject2(v) {
@@ -13187,6 +13909,120 @@ function renderTryReport(result, opts) {
13187
13909
  return lines;
13188
13910
  }
13189
13911
 
13912
+ // src/verbHelp.ts
13913
+ var SELF_HELP_VERBS = /* @__PURE__ */ new Set(["keys", "account", "members", "api", "migrate"]);
13914
+ var VERB_USAGE = {
13915
+ version: "usage: vxil --version \u2014 print the CLI version",
13916
+ init: "usage: vxil init [--template <id>] [--force] \u2014 scaffold vxil.config.ts + functions/ + .vxil/ from a Blueprint Gallery template (`vxil templates` lists them)",
13917
+ templates: "usage: vxil templates \u2014 list the Blueprint Gallery (`vxil init --template <id>` scaffolds one)",
13918
+ quickstart: "usage: vxil quickstart [--features a,b] [--invite <code>] [--env <label>] [--dev [--ttl <h>]] [--no-push] \u2014 create a project + key + enable features in one call",
13919
+ try: "usage: vxil try \u2014 a keyless anonymous sandbox backend (no account); self-expires",
13920
+ login: "usage: vxil login [--email <e>] [--password <p>] \u2014 store a dashboard session in ~/.vxil (bound to the dashboard it came from)",
13921
+ link: "usage: vxil link <slug> [--key <api_key> [--tenant <id>]] [--as dev|<target-name>] [--env <label>] [--mint] \u2014 bind this repo to a project; --tenant is checked against the key's own tenant and refused when the key's tenant cannot be read",
13922
+ keys: "usage: vxil keys mint|list|revoke|perms \u2026 (run `vxil keys --help`)",
13923
+ account: "usage: vxil account [show]|set|password (run `vxil account --help`)",
13924
+ logout: "usage: vxil logout [--json] \u2014 revoke the stored dashboard session where it was obtained and remove it from ~/.vxil (project API keys are untouched)",
13925
+ invites: "usage: vxil invites [list] [--json] \xB7 vxil invites generate [--count <n>] [--json] \xB7 vxil invites accept <token | invite-url> [--json]",
13926
+ members: "usage: vxil members [list]|invite|add|rm|uninvite (run `vxil members --help`)",
13927
+ projects: "usage: vxil projects [list] [--json] \xB7 projects create <slug> [--name <display>] [--json] \xB7 projects rm <slug> [--yes] [--json]",
13928
+ billing: "usage: vxil billing [status] [--json] \xB7 vxil billing upgrade --tier <free|developer|team|business> [--yes] [--json] (upgrade also downgrades: --tier free)",
13929
+ files: "usage: vxil files put <path> --user <user_id> [--content-type <type>] [--json] \xB7 vxil files rm <object_id> [--yes] [--json]",
13930
+ webhooks: "usage: vxil webhooks sources add --provider <stripe|paddle|github|slack|revenuecat|generic> --name <n> [--forward-url <https>] \xB7 sources list \xB7 sources rm <source_id> [--yes] \xB7 sources test <source_id> \xB7 vxil webhooks events replay <event_id> \xB7 events runs <event_id>",
13931
+ search: "usage: vxil search sync run <sourceKey> [--sweep] [--json] \xB7 vxil search sync reset <sourceKey> [--purge] [--yes] [--json]",
13932
+ listen: "usage: vxil listen --forward-to <url> [--dev] [--source <id>] [--events <prefix,\u2026>] [--replay-last <n>] [--interval <s>] [--show-runs] [--raw] \u2014 forward inbound webhook events to a local server",
13933
+ mcp: "usage: vxil mcp install [--client cursor|claude|vscode] [--project] [--print] [--key <api_key>] [--scopes a,b] [--name <server>]",
13934
+ plan: "usage: vxil plan [--dev|--target <name>] [--explain] [--skip-functions] \u2014 what `vxil push` would change: cms schema, feature config, declared API state, functions (a deployed function the config no longer declares shows as `-`); writes nothing",
13935
+ diff: "usage: vxil diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--strict-api-state] [--api-state] [--json] \u2014 the CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error",
13936
+ push: "usage: vxil push [--dev|--target <name>|--prod] [--allow-destructive] [--strict] [--server [--resume <apply_id>]] [--no-gen] [--skip-functions] [--yes] \u2014 apply the config; --allow-destructive also deletes deployed functions and stored AI templates the config no longer declares; --strict exits 1 when anything declared was refused, deferred or left unwired",
13937
+ gen: "usage: vxil gen [--check] [--offline] [--out <f>] [--no-mcp] [--mcp-out <f>] \u2014 generate vxil.types.ts (+ vxil.mcp.json); --offline keeps an existing API_VERSIONS block; --check compares instead of writing",
13938
+ types: "usage: vxil types \u2014 alias of `vxil gen` (run `vxil gen --help`)",
13939
+ doctor: "usage: vxil doctor [--dev|--target <name>] \u2014 preflight: the binding's tenant vs the key's tenant, secrets referenced-vs-stored, per-function hashes, keys (read-only)",
13940
+ architect: 'usage: vxil architect "<describe your app>" [--name <n>] [--out vxil.config.ts] \u2014 an advisory vxil.config draft',
13941
+ versions: "usage: vxil versions <feature> \u2014 the feature's config versions",
13942
+ rollback: "usage: vxil rollback <feature> --to <version>",
13943
+ pull: "usage: vxil pull [--dev|--target <name>] [--out <file=vxil.config.ts>] [--force] \u2014 materialize config + cms schema from the live project",
13944
+ env: "usage: vxil env pull [--dev | --target <name>] [--file <path=.env.local>] [--print] [--no-gitignore]",
13945
+ secrets: "usage: vxil secrets set <feature>/<name> [--value -] \xB7 secrets list [--all-projects] \xB7 secrets rm <feature>/<name> [--yes] (set reads piped stdin or a hidden prompt)",
13946
+ seed: "usage: vxil seed [--dev|--target <name>] \u2014 apply the config `seed` block through import bundles",
13947
+ export: "usage: vxil export <feature> [--out <file>] [--ndjson] [--urls [--pace <seconds>]] \u2014 exit 0 complete \xB7 1 error \xB7 3 download URLs expired before they could be read \xB7 4 incomplete (a table was cut at the server cap with no resume cursor; the file is still written)",
13948
+ import: "usage: vxil import <feature> --file <bundle.json> [--mode merge|replace]",
13949
+ migrate: "usage: vxil migrate plan|schema|data|verify|payments \u2026 (run `vxil migrate --help`)",
13950
+ functions: "usage: vxil functions new|deploy [<name>] [--force]|list|delete|enable|disable [--yes]|invoke ['<json>' | --data '<json>'] [--async]|logs [--tail]|dev <name> [--port <n>] [--trigger <lane>] [--event <file.json>]",
13951
+ cms: "usage: vxil cms status|pull|reindex <collection> [--field <field>]|drop <collection> [--yes]|bulk-rm <collection> --filter '<json>' [--limit <1..100>] [--dry-run] [--yes]",
13952
+ payments: "usage: vxil payments simulate --scenario <refund-pair|cross-platform-unlock|renewal|expiry|past-due-grace|transfer> --user <id> [--tier <t>] [--dev] \u2014 mock/dev projects only",
13953
+ dev: "usage: vxil dev up [--ttl <h>] [--new] | down [--yes] | seed | reset | branch [<name>] [--ttl <h=24>] [--no-env] [--print] | --list | --rm <name> [--yes] | --watch [--target <name>]",
13954
+ config: "usage: vxil config get <feature> \xB7 vxil config draft show|save [--file <f>]|publish|discard <feature> \xB7 vxil config draft duplicate <feature> --from <version>",
13955
+ users: "usage: vxil users add <id> --email <email> \xB7 users list \xB7 users import <users.json|users.csv> [--json]",
13956
+ send: "usage: vxil send <user_id> <template> --data '<json>'",
13957
+ deliveries: "usage: vxil deliveries [--user <id>] [--status <s>] \u2014 recent notification deliveries",
13958
+ api: "usage: vxil api <METHOD> <path> ['<json>' | --data '<json>'] (run `vxil api --help` for the operator cheat-sheet)"
13959
+ };
13960
+ var ALIASES = { "--version": "version", "-v": "version" };
13961
+ function wantsHelp(rest2) {
13962
+ return rest2.includes("--help") || rest2.includes("-h");
13963
+ }
13964
+ function helpRequest(cmd2, rest2) {
13965
+ if (!cmd2 || !wantsHelp(rest2)) return null;
13966
+ const verb = ALIASES[cmd2] ?? cmd2;
13967
+ if (SELF_HELP_VERBS.has(verb)) return { delegate: verb };
13968
+ const usage = VERB_USAGE[verb];
13969
+ return usage === void 0 ? null : { print: usage };
13970
+ }
13971
+ function normalizeHelpArgv(rest2) {
13972
+ if (rest2.includes("-h") && !rest2.includes("--help")) rest2.push("--help");
13973
+ }
13974
+ function isGlobalHelp(cmd2) {
13975
+ return cmd2 === void 0 || cmd2 === "help" || cmd2 === "--help" || cmd2 === "-h";
13976
+ }
13977
+
13978
+ // src/keyTenant.ts
13979
+ function knownTenantId(id) {
13980
+ return typeof id === "string" && id.length > 0 && id !== "(unknown)";
13981
+ }
13982
+ function judgeKeyTenant(bindingTenant, read) {
13983
+ if (!knownTenantId(bindingTenant)) {
13984
+ return { kind: "unknown", reason: "the binding records no tenant id (VXIL_API_KEY with no project binding, or an older link)" };
13985
+ }
13986
+ if (read instanceof Error) return { kind: "unknown", reason: `GET /v1/features failed (${read.message})` };
13987
+ if (read.status !== 200) {
13988
+ const code = read.body?.error?.code;
13989
+ return { kind: "unknown", reason: `GET /v1/features \u2192 ${read.status}${code ? ` ${code}` : ""}` };
13990
+ }
13991
+ const keyTenant = read.body?.meta?.tenant_id;
13992
+ if (!knownTenantId(keyTenant)) return { kind: "unknown", reason: "the server named no tenant for this key (meta.tenant_id absent)" };
13993
+ return keyTenant === bindingTenant ? { kind: "match", keyTenant } : { kind: "mismatch", keyTenant, bindingTenant };
13994
+ }
13995
+ function keyTenantRefusal(v, t) {
13996
+ const relink = `vxil link ${t.slug ?? "<slug>"} --key <key>${t.as ? ` --as ${t.as}` : ""}`;
13997
+ const fix = t.keySource === "VXIL_API_KEY" ? "unset VXIL_API_KEY (it overrides the binding's key) or point it at the bound tenant's key" : `re-link the slot with the right key: \`${relink}\``;
13998
+ return `${t.where} is bound to tenant ${v.bindingTenant} (labelled ${t.envLabel}), but the key from ${t.keySource} belongs to tenant ${v.keyTenant} (GET /v1/features meta.tenant_id) \u2014 nothing was written. The push promotion gate keys on the slot's label, so a key for another tenant here could write to it ungated. Fix: ${fix}; \`vxil doctor\` shows the binding and the key side by side.`;
13999
+ }
14000
+ function keyTenantCheck(v) {
14001
+ if (v.kind === "match") return { name: "binding tenant = key tenant", ok: true, detail: `the key belongs to the bound tenant ${v.keyTenant}` };
14002
+ if (v.kind === "mismatch") {
14003
+ return {
14004
+ name: "binding tenant = key tenant",
14005
+ ok: false,
14006
+ detail: `the binding names tenant ${v.bindingTenant} but the key belongs to tenant ${v.keyTenant} \u2014 every write verb refuses this target until it is re-linked`
14007
+ };
14008
+ }
14009
+ return { name: "binding tenant = key tenant", ok: true, warn: true, detail: `not verified: ${v.reason}` };
14010
+ }
14011
+
14012
+ // src/pushOutcome.ts
14013
+ function pushExitCode(p, strict) {
14014
+ const lines = [];
14015
+ for (const h of p.hard) lines.push(`push problem: ${h}`);
14016
+ for (const s of p.strict) lines.push(`${strict ? "push problem (--strict)" : "warning"}: ${s}`);
14017
+ const failing = p.hard.length + (strict ? p.strict.length : 0);
14018
+ if (failing > 0) {
14019
+ lines.push(`push applied, but ${failing} problem(s) remain \u2014 exit 1${!strict && p.strict.length ? ` (${p.strict.length} more would fail under --strict)` : ""}`);
14020
+ return { code: 1, lines };
14021
+ }
14022
+ if (p.strict.length) lines.push(`${p.strict.length} warning(s) \u2014 \`vxil push --strict\` turns them into exit 1 for CI`);
14023
+ return { code: 0, lines };
14024
+ }
14025
+
13190
14026
  // src/paymentsCmd.ts
13191
14027
  var SIMULATE_SCENARIOS = [
13192
14028
  "refund-pair",
@@ -13855,7 +14691,7 @@ function inferMapping(source, opts = {}) {
13855
14691
  const cmsNames = [...cmsByName.keys()];
13856
14692
  const loadOrder = topoOrder(cmsNames, source.foreignKeys, profile?.table, warnings);
13857
14693
  if (source.authUsers?.present) {
13858
- residuals.push(source.authUsers.hasPasswordHash ? "passwords: hashes are NOT imported (auth is scrypt-locked; the foreign-hash import is signal-gated, not built) \u2014 users re-auth via magic-link/OTP/social/reset on first sign-in" : "auth: credentials do not transfer \u2014 users re-auth via their existing OTP/magic-link/social flow post-cutover");
14694
+ residuals.push(source.authUsers.hasPasswordHash ? "passwords: hashes are NOT imported by default \u2014 `vxil migrate data --with-password-hashes` carries them (bcrypt, verified and rehashed at each user's first sign-in); without it users re-auth via magic-link/OTP/social/reset on first sign-in" : "auth: credentials do not transfer \u2014 users re-auth via their existing OTP/magic-link/social flow post-cutover");
13859
14695
  }
13860
14696
  for (const chk of source.checks ?? []) {
13861
14697
  if (!cmsByName.has(lastSeg(chk.table))) continue;
@@ -14438,6 +15274,93 @@ async function loadUsers({ deps, adapter, mapping, state, save, delta = false })
14438
15274
  return result;
14439
15275
  }
14440
15276
 
15277
+ // src/migrate/loaders/credentials.ts
15278
+ var CREDENTIALS_BATCH_MAX = 500;
15279
+ var KEY = "users:credentials";
15280
+ function credentialEntry(row2, withHashes) {
15281
+ return {
15282
+ id: row2.id,
15283
+ ...withHashes && row2.password_hash ? { password_hash: row2.password_hash } : {},
15284
+ ...row2.email_verified ? { email_verified: true } : {}
15285
+ };
15286
+ }
15287
+ async function loadCredentials2({ deps, adapter, state, save, opts, delta = false }) {
15288
+ const result = { table: KEY, read: 0, written: 0, skipped: 0, errors: [], cursor: null };
15289
+ if (!adapter.readCredentials) {
15290
+ result.errors.push("credentials: this source has no credential reader (supabase only today) \u2014 post the hashes yourself with the SDK `auth.users.import()`, or drop --with-password-hashes / --oidc-issuer");
15291
+ return result;
15292
+ }
15293
+ const cursors = cursorsFor(state, delta);
15294
+ const sent = hashesFor(state, KEY);
15295
+ if (cursors[KEY] === null) {
15296
+ 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)`);
15297
+ return result;
15298
+ }
15299
+ const reasons = /* @__PURE__ */ new Map();
15300
+ let withPassword = 0;
15301
+ for await (const batch of adapter.readCredentials(cursors[KEY] ?? void 0)) {
15302
+ const entries = [];
15303
+ const digests = [];
15304
+ for (const row2 of batch.rows) {
15305
+ result.read++;
15306
+ const entry = credentialEntry(row2, opts.withHashes);
15307
+ const h = rowHash({ ...entry, issuer: opts.oidcIssuer ?? null });
15308
+ if (sent[row2.id] === h) {
15309
+ result.skipped++;
15310
+ continue;
15311
+ }
15312
+ if (entry.password_hash) withPassword++;
15313
+ entries.push(entry);
15314
+ digests.push(h);
15315
+ }
15316
+ if (deps.dryRun) continue;
15317
+ for (let i = 0; i < entries.length; i += CREDENTIALS_BATCH_MAX) {
15318
+ const chunk = entries.slice(i, i + CREDENTIALS_BATCH_MAX);
15319
+ const res = await deps.api("POST", "/v1/auth/users/import", {
15320
+ users: chunk,
15321
+ ...opts.oidcIssuer ? { oidc_issuer: opts.oidcIssuer } : {}
15322
+ });
15323
+ if (res.status !== 200 || res.body.error) {
15324
+ const hint = res.body.error?.code === "capability_not_enabled" ? " \u2014 enable auth (with emailPassword) on this project first" : res.body.error?.code === "server_only" || res.body.error?.code === "insufficient_scope" ? " \u2014 run the migration with a SERVER key holding auth:write" : "";
15325
+ result.errors.push(apiErrorLine(`credentials: POST /v1/auth/users/import (${chunk.length} users) failed`, res) + hint + " \u2014 the cursor was NOT advanced; fix and re-run (the import is idempotent)");
15326
+ result.cursor = cursors[KEY] ?? null;
15327
+ return result;
15328
+ }
15329
+ const data2 = res.body.data ?? {};
15330
+ const taken = (data2.results ?? []).filter((r) => r.status === "skipped" && r.reason === "id_taken").length;
15331
+ if (taken > 0) {
15332
+ 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.`);
15333
+ result.cursor = cursors[KEY] ?? null;
15334
+ return result;
15335
+ }
15336
+ for (const r of data2.results ?? []) {
15337
+ if (r.status === "skipped") {
15338
+ result.skipped++;
15339
+ reasons.set(r.reason ?? "unknown", (reasons.get(r.reason ?? "unknown") ?? 0) + 1);
15340
+ } else {
15341
+ result.written++;
15342
+ }
15343
+ }
15344
+ for (let j = 0; j < chunk.length; j++) sent[chunk[j].id] = digests[i + j];
15345
+ }
15346
+ cursors[KEY] = batch.nextCursor;
15347
+ save();
15348
+ if (batch.nextCursor === null) break;
15349
+ }
15350
+ if (deps.dryRun) {
15351
+ 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");
15352
+ } else {
15353
+ cursors[KEY] = null;
15354
+ save();
15355
+ }
15356
+ if (reasons.size > 0) {
15357
+ const list = [...reasons].map(([r, n]) => `${r}=${n}`).join(" ");
15358
+ deps.log(`credentials: ${[...reasons.values()].reduce((a, b2) => a + b2, 0)} user(s) not imported (${list}) \u2014 they sign in once another way (magic link, OTP, a reset); \`not_registered\` means the users step did not load them`);
15359
+ }
15360
+ result.cursor = deps.dryRun ? cursors[KEY] ?? null : null;
15361
+ return result;
15362
+ }
15363
+
14441
15364
  // src/migrate/loaders/schema.ts
14442
15365
  function mappingToCollection(m) {
14443
15366
  const fields = {};
@@ -14587,7 +15510,10 @@ function objectOwner(path, filesOwner) {
14587
15510
  const seg = path.split("/").find((s) => s.length > 0);
14588
15511
  return seg;
14589
15512
  }
14590
- async function loadFiles({ deps, plan, adapter, state, save, filesOwner, fetchImpl, delta = false }) {
15513
+ var SHARED_LINK_ASK_MS = 100 * 365 * 24 * 3600 * 1e3;
15514
+ var SHARED_LINK_WARN_MS = 365 * 24 * 3600 * 1e3;
15515
+ var isUrl = (v) => typeof v === "string" && /^https?:\/\//i.test(v);
15516
+ async function loadFiles({ deps, plan, adapter, state, save, filesOwner, fetchImpl, delta = false, fileLinks = "object_id" }) {
14591
15517
  const results = [];
14592
15518
  if (plan.storageBuckets.length === 0) return results;
14593
15519
  if (!adapter.readObjects) {
@@ -14629,6 +15555,31 @@ async function loadFiles({ deps, plan, adapter, state, save, filesOwner, fetchIm
14629
15555
  }
14630
15556
  const resolve8 = filesResolver(state);
14631
15557
  const cursors = cursorsFor(state, delta);
15558
+ const links = fileLinks === "shared-link" ? state.idMap["links:files"] ??= {} : null;
15559
+ const publicObject = /* @__PURE__ */ new Set();
15560
+ if (links) {
15561
+ for (const b2 of plan.storageBuckets) {
15562
+ if (b2.public !== true) continue;
15563
+ for (const oid of Object.values(state.idMap[`files:${b2.name}`] ?? {})) publicObject.add(oid);
15564
+ }
15565
+ }
15566
+ let shortLived = null;
15567
+ const sharedLink = async (objectId) => {
15568
+ const hit = links[objectId];
15569
+ if (hit !== void 0) return { url: hit };
15570
+ const res = await deps.api("POST", `/v1/files/${encodeURIComponent(objectId)}/shared-links`, {
15571
+ expires_at: new Date(Date.now() + SHARED_LINK_ASK_MS).toISOString()
15572
+ });
15573
+ const url = res.body.data?.url;
15574
+ if (res.status !== 201 && res.status !== 200 || typeof url !== "string") {
15575
+ const hint = res.body.error?.code === "shared_links_disabled" ? " \u2014 enable features.files.sharedLinks (and raise its maxTtl) before a shared-link run" : "";
15576
+ return { err: apiErrorLine(`files: shared link for ${objectId} failed`, res) + hint };
15577
+ }
15578
+ const exp = Date.parse(String(res.body.data?.expires_at ?? ""));
15579
+ if (Number.isFinite(exp) && exp - Date.now() < SHARED_LINK_WARN_MS) shortLived ??= String(res.body.data?.expires_at);
15580
+ links[objectId] = url;
15581
+ return { url };
15582
+ };
14632
15583
  for (const mapping of plan.tables) {
14633
15584
  if (mapping.target !== "cms" || !mapping.collection) continue;
14634
15585
  const storageFields = mapping.fields.filter((f) => f.storageObject === true);
@@ -14646,6 +15597,7 @@ async function loadFiles({ deps, plan, adapter, state, save, filesOwner, fetchIm
14646
15597
  const pk = sourcePkColumn(mapping);
14647
15598
  const enc2 = encodeURIComponent(mapping.collection);
14648
15599
  let misses = 0;
15600
+ let privateUrls = 0;
14649
15601
  const columns = [pk, ...storageFields.map((f) => f.source)];
14650
15602
  for await (const batch of adapter.readRows(table, cursors[rk] ?? void 0, { columns })) {
14651
15603
  for (const row2 of batch.rows) {
@@ -14662,12 +15614,33 @@ async function loadFiles({ deps, plan, adapter, state, save, filesOwner, fetchIm
14662
15614
  result.skipped++;
14663
15615
  continue;
14664
15616
  }
14665
- const h = rowHash(patch);
15617
+ const urlValued = links ? storageFields.filter((f) => patch[f.target] !== void 0 && isUrl(row2[f.source])) : [];
15618
+ const urlFields = urlValued.filter((f) => publicObject.has(patch[f.target])).map((f) => f.target);
15619
+ privateUrls += urlValued.length - urlFields.length;
15620
+ const h = rowHash(urlFields.length ? { ...patch, __shared: urlFields } : patch);
14666
15621
  if (linked[sourceId] === h) {
14667
15622
  result.skipped++;
14668
15623
  continue;
14669
15624
  }
14670
15625
  if (deps.dryRun) continue;
15626
+ let linkErr = null;
15627
+ for (const t of urlFields) {
15628
+ const l = await sharedLink(patch[t]);
15629
+ if ("err" in l) {
15630
+ linkErr = l.err;
15631
+ break;
15632
+ }
15633
+ patch[t] = l.url;
15634
+ }
15635
+ if (linkErr) {
15636
+ result.errors.push(linkErr);
15637
+ save();
15638
+ if (result.errors.length >= ROW_FAILURE_CAP) {
15639
+ result.errors.push(`files relink ${table}: ABORTED after ${ROW_FAILURE_CAP} failure(s) \u2014 fix and re-run (the '${rk}' cursor resumes)`);
15640
+ return results;
15641
+ }
15642
+ continue;
15643
+ }
14671
15644
  const res = await deps.api("PATCH", `/v1/cms/items/${enc2}/${encodeURIComponent(itm)}`, { data: patch });
14672
15645
  if (res.status !== 200) {
14673
15646
  result.errors.push(apiErrorLine(`files relink ${table} row '${sourceId}' (item ${itm})`, res));
@@ -14690,6 +15663,13 @@ async function loadFiles({ deps, plan, adapter, state, save, filesOwner, fetchIm
14690
15663
  if (misses > 0) {
14691
15664
  deps.log(`\u26A0 files relink ${table}: ${misses} storage value(s) had no uploaded object match \u2014 left as the raw source value (check bucket coverage)`);
14692
15665
  }
15666
+ if (privateUrls > 0) {
15667
+ deps.log(`\u26A0 files relink ${table}: ${privateUrls} stored URL(s) point at objects of a bucket that is not public \u2014 they became the object id, not a keyless shared link (a private file never becomes public by migration). Set "public": true on a bucket in migrate/plan.json only if its objects really were public.`);
15668
+ }
15669
+ if (shortLived) {
15670
+ deps.log(`\u26A0 files: shared links expire at ${shortLived} (features.files.sharedLinks.maxTtl) \u2014 stored URLs stop working then; raise maxTtl and re-run, or switch the app to object ids`);
15671
+ shortLived = null;
15672
+ }
14693
15673
  if (!deps.dryRun) {
14694
15674
  cursors[rk] = null;
14695
15675
  save();
@@ -15417,6 +16397,16 @@ async function runData(deps, plan, adapter, opts = {}) {
15417
16397
  delta: delta !== void 0
15418
16398
  }));
15419
16399
  }
16400
+ if (opts.credentials && want("credentials")) {
16401
+ results.push(await loadCredentials2({
16402
+ deps: g.deps,
16403
+ adapter,
16404
+ state: g.state,
16405
+ save: g.save,
16406
+ opts: opts.credentials,
16407
+ delta: delta !== void 0
16408
+ }));
16409
+ }
15420
16410
  const cmsByTable = new Map(plan.tables.filter((t) => t.target === "cms").map((t) => [t.table, t]));
15421
16411
  const cmsWanted = plan.loadOrder.filter((t) => cmsByTable.has(t) && want("cms", t));
15422
16412
  if (cmsWanted.length > 0 && !g.state.schemaPushed && opts.assumeSchema !== true) {
@@ -15472,6 +16462,7 @@ async function runData(deps, plan, adapter, opts = {}) {
15472
16462
  save: g.save,
15473
16463
  delta: delta !== void 0,
15474
16464
  ...opts.filesOwner !== void 0 ? { filesOwner: opts.filesOwner } : {},
16465
+ ...opts.fileLinks ? { fileLinks: opts.fileLinks } : {},
15475
16466
  ...putFetch !== void 0 ? { fetchImpl: putFetch } : {}
15476
16467
  }));
15477
16468
  }
@@ -15603,7 +16594,7 @@ ${notes.length ? `${notes.join("\n")}
15603
16594
  `;
15604
16595
  }
15605
16596
  var STANDING_CAVEATS = [
15606
- "Passwords: never imported (auth is scrypt-locked; the foreign-hash import is signal-gated, not built) \u2014 users re-auth via magic-link/OTP/social/reset on first sign-in.",
16597
+ "Passwords: imported only with `vxil migrate data --with-password-hashes` (supabase sources: bcrypt, verified and replaced at each user's first sign-in \u2014 no forced re-sign-in); without it, or for a hash it cannot accept, users re-auth once via magic-link/OTP/social/reset.",
15607
16598
  "Push device tokens: vxil has no push provider \u2014 token rows migrate as ordinary owner-scoped cms app data, and sending is a function over egress (the push-notifications blueprint); or skip those tables and let clients re-enroll post-cutover.",
15608
16599
  "Subscriptions / entitlements / tier: NEVER seeded (provider-derived truth) \u2014 rebuild from your live provider webhooks after cutover.",
15609
16600
  "DB triggers / RPCs / pg_cron / row-level access rules: not auto-migrated \u2014 hand-wire as vxil functions (cms-hook/cron/http triggers; cms-hooks are async post-commit, never in-transaction) + per-collection ownerField.",
@@ -15652,7 +16643,7 @@ function formatPlanSummary(plan) {
15652
16643
  out.push(` vector-search: ${v.table}.${v.column} (${v.dimensions}d) \u2192 '${v.collection}' (text: ${v.textFields.join(", ") || "NONE \u2014 set textFields"})`);
15653
16644
  }
15654
16645
  if (plan.storageBuckets.length) {
15655
- out.push(` storage buckets: ${plan.storageBuckets.map((b2) => `${b2.name}${b2.objectCount !== void 0 ? ` (${b2.objectCount})` : ""}`).join(", ")}`);
16646
+ out.push(` storage buckets: ${plan.storageBuckets.map((b2) => `${b2.name}${b2.objectCount !== void 0 ? ` (${b2.objectCount})` : ""}${b2.public === true ? " [public]" : ""}`).join(", ")}`);
15656
16647
  }
15657
16648
  return out.join("\n");
15658
16649
  }
@@ -15738,7 +16729,7 @@ async function runMigrateSchema(io, opts = {}) {
15738
16729
  if (!apply) io.log("\n(dry-run \u2014 re-run with --apply to create the collections)");
15739
16730
  return result;
15740
16731
  }
15741
- var DATA_STEPS = /* @__PURE__ */ new Set(["users", "cms", "vectors", "files", "payments"]);
16732
+ var DATA_STEPS = /* @__PURE__ */ new Set(["users", "credentials", "cms", "vectors", "files", "payments"]);
15742
16733
  async function runMigrateData(io, opts = {}) {
15743
16734
  const plan = readPlanFile(io.cwd);
15744
16735
  const p = migratePaths(io.cwd);
@@ -15750,6 +16741,10 @@ async function runMigrateData(io, opts = {}) {
15750
16741
  throw new Error("--prune deletes only during an incremental pass \u2014 add --since <time>");
15751
16742
  }
15752
16743
  const since = opts.since !== void 0 ? new Date(Date.parse(opts.since)).toISOString() : void 0;
16744
+ if (opts.oidcIssuer !== void 0 && !/^https:\/\/[^\s?#]+$/.test(opts.oidcIssuer)) {
16745
+ throw new Error(`--oidc-issuer '${opts.oidcIssuer}' must be an https URL with no query or fragment (Supabase: https://<ref>.supabase.co/auth/v1)`);
16746
+ }
16747
+ const credentials = opts.withPasswordHashes || opts.oidcIssuer !== void 0 ? { withHashes: opts.withPasswordHashes === true, ...opts.oidcIssuer !== void 0 ? { oidcIssuer: opts.oidcIssuer } : {} } : void 0;
15753
16748
  const tableNames = new Set(plan.tables.map((t) => t.table));
15754
16749
  for (const o of opts.only ?? []) {
15755
16750
  if (!DATA_STEPS.has(o) && !tableNames.has(o)) {
@@ -15776,7 +16771,9 @@ async function runMigrateData(io, opts = {}) {
15776
16771
  ...opts.fetchImpl !== void 0 ? { fetchImpl: opts.fetchImpl } : {},
15777
16772
  ...since !== void 0 ? { since } : {},
15778
16773
  ...opts.prune ? { prune: true } : {},
15779
- ...specs ? { payments: specs } : {}
16774
+ ...specs ? { payments: specs } : {},
16775
+ ...credentials ? { credentials } : {},
16776
+ ...opts.fileLinks ? { fileLinks: opts.fileLinks } : {}
15780
16777
  });
15781
16778
  io.log(`${execute ? "\nmigrate data" : "\nmigrate data (DRY-RUN \u2014 zero writes)"}${since ? ` \u2014 incremental since ${since}${opts.prune ? " + prune" : ""}` : ""}:`);
15782
16779
  for (const r of results) io.log(formatLoadResult(r));
@@ -16502,7 +17499,7 @@ export default defineConfig({
16502
17499
  "functions": {
16503
17500
  "ask-journal.ts": "// ask-journal.ts \u2014 \"ASK YOUR JOURNAL\" (a vxil function, \xA77.3).\n//\n// Trigger: http \u2014 POST /v1/fn/ask-journal { question, user_id? }. Runs ONE\n// retrieval-augmented call: POST /v1/rag/answer over the `journal` index the\n// on-entry-written function keeps fed. rag retrieves top-k chunks from\n// vector-search, grounds the tenant-owned prompt, generates via the ai feature,\n// and returns the answer WITH citations pointing at the exact entries used \u2014\n// this function is a thin, scoped wrapper (rag:write only).\n//\n// In end-user mode the verified principal is propagated automatically into the\n// scoped token, so retrieval is owner-scoped; in server mode an optional\n// `user_id` rides along for per-user metering.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload`\ntype Env = HttpFunctionEnvelope<{ question?: string; user_id?: string }>;\ninterface Citation { chunk_id?: string; doc_id?: string; score?: number }\ninterface AnswerRes { data?: { answer?: string; citations?: Citation[]; usage?: Record<string, unknown> } }\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 rag = env.scoped_jwts?.rag;\n if (!rag) return json({ error: 'missing rag scope' }, 403);\n\n const question = String(env.payload?.question ?? '').trim();\n if (!question) return json({ error: 'question required', example: { question: 'what made me happy last month?' } }, 400);\n\n const res = await fetch(`${base}/v1/rag/answer`, {\n method: 'POST',\n headers: { authorization: `Bearer ${rag}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n query: question.slice(0, 2000),\n collection: 'journal', // = rag config defaultCollection; explicit for clarity\n ...(env.payload?.user_id ? { user_id: env.payload.user_id } : {}),\n }),\n });\n if (!res.ok) {\n // A missing index is NOT a 404 here: rag's retrieve leg wraps a\n // vector-search failure as 502 retrieval_failed and attaches the\n // downstream error under error.upstream (only a 501 passes through),\n // so detect collection_not_found in the BODY, not the status. The\n // index is a one-time setup (see the template README).\n const errBody = (await res.json().catch(() => null)) as\n { error?: { code?: string; upstream?: { code?: string } } } | null;\n const code = errBody?.error?.upstream?.code ?? errBody?.error?.code;\n if (res.status === 404 || code === 'collection_not_found') {\n return json({ error: 'journal index not found', hint: 'POST /v1/search/collections {\"collection\":\"journal\"} once, then write an entry' }, 404);\n }\n return json({ error: 'answer_failed', status: res.status }, 502);\n }\n\n const body = (await res.json()) as AnswerRes;\n return json({\n answer: body.data?.answer ?? '',\n // provenance: which entries grounded the answer (doc_id = the entry's item_id)\n sources: (body.data?.citations ?? []).map((c) => ({ entry_id: c.doc_id, score: c.score })),\n }, 200);\n },\n};\n\n// \u2500\u2500 tiny helper \u2500\u2500\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
16504
17501
  "on-entry-written.ts": "// on-entry-written.ts \u2014 AI ENRICHMENT ON WRITE (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `entries`. The hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// entry by id (through the edge, tenant-scoped), then:\n// 1. asks the ai feature (POST /v1/ai/generate, raw-prompt mode) for a\n// ONE-sentence summary and a ONE-word mood,\n// 2. PATCHes them back onto the entry (merge-patch; the summary-present LATCH\n// keeps our own write-back from re-enriching \u2014 clear `summary` to redo),\n// 3. ingests title+body into the rag retrieval index (POST /v1/rag/ingest/\n// journal \u2014 the vector-search passthrough) so ask-journal can ground on it.\n// At-least-once delivery is safe to redeliver: the summary latch skips a\n// re-enrich, the PATCH is idempotent by content, and the ingest converges \u2014\n// vector-search upserts by doc_id, so re-ingesting the same entry re-indexes\n// in place rather than duplicating.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CmsHookFunctionEnvelope;\ninterface EntryData { title?: string; body?: string; summary?: string; mood?: string; written_at?: string; user_id?: string }\ninterface Item { data?: { data?: EntryData } }\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 const ai = env.scoped_jwts?.ai;\n const rag = env.scoped_jwts?.rag;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (functions.md \xA73); this guard stays as belt-and-braces.\n if (env.payload?.collection !== 'entries' || !cms || !ai || !rag || !itemId) {\n return Response.json({ skipped: true });\n }\n\n // Re-fetch the entry (the payload carries only the id \u2014 never trust inline fields).\n const res = await fetch(`${base}/v1/cms/items/entries/${itemId}`, { headers: H(cms) });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const entry = ((await res.json()) as Item).data?.data ?? {};\n if (entry.summary) return Response.json({ skipped: true, reason: 'already enriched' });\n if (!entry.body) return Response.json({ skipped: true, reason: 'no body yet' });\n\n // 1. AI enrichment \u2014 two small raw-prompt generations ({ data: { text } }).\n const text = entry.body.slice(0, 6000);\n const summary = clip(await generate(base, ai,\n `Summarize this journal entry in exactly one sentence, first person:\\n\\n${text}`, 80, entry.user_id), 400);\n const moodRaw = await generate(base, ai,\n `Answer with ONE lowercase word (e.g. joyful, anxious, calm, tired) naming the dominant mood of this journal entry:\\n\\n${text}`, 8, entry.user_id);\n const mood = (moodRaw.trim().split(/\\s+/)[0] ?? '').toLowerCase().replace(/[^a-z-]/g, '').slice(0, 24);\n if (!summary) return Response.json({ skipped: true, reason: 'ai unavailable' });\n\n // 2. PATCH the derived fields back (merge-patch keys; bumps `version`).\n const patch = await fetch(`${base}/v1/cms/items/entries/${itemId}`, {\n method: 'PATCH',\n headers: H(cms),\n body: JSON.stringify({ data: { summary, ...(mood ? { mood } : {}) } }),\n });\n\n // 3. Ingest into the retrieval index (rag \u2192 vector-search passthrough, 202).\n // Idempotent by doc_id: vector-search UPSERTs on (collection, doc_id), so a\n // redelivered hook (or an edited entry) re-indexes in place.\n const ing = await fetch(`${base}/v1/rag/ingest/journal`, {\n method: 'POST',\n headers: H(rag),\n body: JSON.stringify({\n doc_id: itemId,\n ...(entry.user_id ? { user_id: entry.user_id } : {}),\n text: `${entry.title ?? ''}\\n\\n${entry.body}`,\n metadata: { ...(mood ? { mood } : {}), ...(entry.written_at ? { written_at: entry.written_at } : {}) },\n }),\n });\n return Response.json({\n enriched: patch.ok,\n mood,\n ingested: ing.ok,\n // the index is a one-time setup: POST /v1/search/collections {\"collection\":\"journal\"}\n ...(ing.status === 404 ? { hint: 'create the journal index first (see the template README)' } : {}),\n });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n/** One raw-prompt sync generation; '' on any failure (enrichment is best-effort). */\nasync function generate(base: string, jwt: string, prompt: string, maxTokens: number, userId?: string): Promise<string> {\n const r = await fetch(`${base}/v1/ai/generate`, {\n method: 'POST',\n headers: H(jwt),\n body: JSON.stringify({ prompt, max_tokens: maxTokens, ...(userId ? { user_id: userId } : {}) }),\n }).catch(() => null);\n if (!r || !r.ok) return '';\n return String(((await r.json()) as { data?: { text?: string } }).data?.text ?? '');\n}\nconst clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '\u2026' : s);\n",
16505
- "weekly-digest.ts": "// weekly-digest.ts \u2014 THE WEEKLY DIGEST (a vxil function, \xA77.3).\n//\n// Trigger: cron ('0 8 * * 1' \u2014 Mondays 08:00 UTC, delivered via the jobs\n// schedule the control-plane reconciles per cron binding). Lists the last 7\n// days of entries (written_at rides the t1 index slot, so the $gte range +\n// sort=-written_at are index-served), groups them per writer, and sends each\n// writer ONE notifications digest ({ subject, paragraph } on the built-in\n// 'transactional' template).\n//\n// Delivery notes: notifications resolves user_id against your end users \u2014 a\n// writer with no email fails that ONE send (user_email_missing) and the loop\n// continues. The per-user Idempotency-Key (envelope key + user id) makes the\n// at-least-once cron redelivery never double-send.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface EntryData { title?: string; mood?: string; user_id?: string; written_at?: 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 const notif = env.scoped_jwts?.notifications;\n if (!cms || !notif) return Response.json({ skipped: true, reason: 'missing cms/notifications scope' });\n\n // 1. the week's entries, newest first (t1-slotted range + sort).\n const since = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();\n const filter = encodeURIComponent(JSON.stringify({ written_at: { $gte: since } }));\n const res = await fetch(`${base}/v1/cms/items/entries?filter=${filter}&sort=-written_at&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ skipped: true, reason: `list ${res.status}` });\n const body = (await res.json()) as { data?: { items?: { id: string; data: EntryData }[] } };\n const items = body.data?.items ?? [];\n\n // 2. group per writer.\n const byUser = new Map<string, EntryData[]>();\n for (const it of items) {\n const uid = it.data.user_id;\n if (!uid) continue;\n const list = byUser.get(uid) ?? [];\n list.push(it.data);\n byUser.set(uid, list);\n }\n\n // 3. one digest send per writer (best-effort per user; the loop never aborts).\n let sent = 0;\n for (const [uid, entries] of byUser) {\n const lines = entries\n .slice(0, 10)\n .map((e) => `\u2022 ${e.title ?? 'Untitled'}${e.mood ? ` (${e.mood})` : ''}`)\n .join('\\n');\n const ok = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n 'idempotency-key': `${env.idempotency_key ?? 'weekly-digest'}:${uid}`,\n },\n body: JSON.stringify({\n user_id: uid,\n template: 'transactional',\n data: {\n subject: `Your journal week \u2014 ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`,\n paragraph: `You wrote ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'} this week:\\n${lines}`,\n },\n }),\n }).then((r) => r.ok).catch(() => false);\n if (ok) sent += 1;\n }\n\n return Response.json({ entries: items.length, writers: byUser.size, sent });\n },\n};\n"
17502
+ "weekly-digest.ts": "// weekly-digest.ts \u2014 THE WEEKLY DIGEST (a vxil function, \xA77.3).\n//\n// Trigger: cron ('0 8 * * 1' \u2014 Mondays 08:00 UTC, delivered via the jobs\n// schedule the control-plane reconciles per cron binding). Lists the last 7\n// days of entries (written_at rides the t1 index slot, so the $gte range is\n// index-served) \u2014 ALL of them, page by page, up to MAX_PAGES (a bigger week is\n// reported `truncated: true` rather than silently dropping writers) \u2014 groups\n// them per writer, and sends each writer ONE notifications digest\n// ({ subject, paragraph } on the built-in 'transactional' template).\n//\n// Delivery notes: notifications resolves user_id against your end users \u2014 a\n// writer with no email fails that ONE send (user_email_missing) and the loop\n// continues. The per-user Idempotency-Key (envelope key + user id) makes the\n// at-least-once cron redelivery never double-send.\n\n// cron-walk: complete-per-tick \u2014 the week is read to its end each Monday (next_cursor), under MAX_PAGES.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\n/** Pages of 100 entries read per run \u2014 5,000 entries a week. */\nconst MAX_PAGES = 50;\n\ninterface EntryData { title?: string; mood?: string; user_id?: string; written_at?: 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 const notif = env.scoped_jwts?.notifications;\n if (!cms || !notif) return Response.json({ skipped: true, reason: 'missing cms/notifications scope' });\n\n // 1. the week's entries \u2014 every page, not just the first 100 (t1-slotted\n // range; the default order pages with a plain item-id cursor).\n const since = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();\n const filter = encodeURIComponent(JSON.stringify({ written_at: { $gte: since } }));\n const items: { item_id: string; data: EntryData }[] = [];\n let cursor: string | null = null;\n let truncated = false;\n for (let page = 0; ; page++) {\n if (page >= MAX_PAGES) { truncated = true; break; }\n const res = await fetch(`${base}/v1/cms/items/entries?filter=${filter}&limit=100`\n + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''), {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ skipped: true, reason: `list ${res.status}` });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: EntryData }[]; next_cursor?: string | null } };\n items.push(...(body.data?.items ?? []));\n cursor = body.data?.next_cursor ?? null;\n if (!cursor) break;\n }\n\n // 2. group per writer.\n const byUser = new Map<string, EntryData[]>();\n for (const it of items) {\n const uid = it.data.user_id;\n if (!uid) continue;\n const list = byUser.get(uid) ?? [];\n list.push(it.data);\n byUser.set(uid, list);\n }\n\n // 3. one digest send per writer (best-effort per user; the loop never aborts).\n let sent = 0;\n for (const [uid, entries] of byUser) {\n const lines = entries\n .slice(0, 10)\n .map((e) => `\u2022 ${e.title ?? 'Untitled'}${e.mood ? ` (${e.mood})` : ''}`)\n .join('\\n');\n const ok = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n 'idempotency-key': `${env.idempotency_key ?? 'weekly-digest'}:${uid}`,\n },\n body: JSON.stringify({\n user_id: uid,\n template: 'transactional',\n data: {\n subject: `Your journal week \u2014 ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`,\n paragraph: `You wrote ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'} this week:\\n${lines}`,\n },\n }),\n }).then((r) => r.ok).catch(() => false);\n if (ok) sent += 1;\n }\n\n return Response.json({ entries: items.length, writers: byUser.size, sent, truncated });\n },\n};\n"
16506
17503
  }
16507
17504
  },
16508
17505
  {
@@ -16536,6 +17533,30 @@ export default defineConfig({
16536
17533
  "triage-ticket.ts": "// triage-ticket.ts \u2014 CLASSIFY EVERY NEW TICKET (a vxil function).\n//\n// Trigger: cmsHook on `tickets`. A hook delivery carries ids, not the row\n// ({ event, collection, item_id }), so the function RE-FETCHES the ticket\n// rather than trusting inline fields \u2014 and delivery is at-least-once, so it\n// skips a ticket that already carries a category instead of re-billing a model\n// call on a redelivery.\n//\n// The one model call is a FORCED-LABEL verdict: `POST /v1/ai/classify` takes the\n// label set and returns exactly one of them (plus a confidence and a one-line\n// rationale). That is the difference between a classifier and a chat prompt \u2014\n// the answer cannot be a paragraph, a new label, or an apology.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\nconst LABELS = ['billing', 'bug', 'how_to', 'other'];\n\ntype Env = CmsHookFunctionEnvelope;\ninterface TicketData { subject?: string; body?: string; category?: 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 const ai = env.scoped_jwts?.ai;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (functions.md \xA73); this guard stays as belt-and-braces.\n if (!cms || !ai || !itemId || env.payload?.collection !== 'tickets') {\n return Response.json({ skipped: true, reason: 'not a tickets hook' });\n }\n // The cms.item.* subscription also delivers updates \u2014 only triage a create.\n if (!String(env.payload?.event ?? '').endsWith('.created')) {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n\n const read = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!read.ok) return Response.json({ skipped: true, reason: `fetch ${read.status}` });\n const ticket = ((await read.json()) as { data?: { data?: TicketData } }).data?.data ?? {};\n // Already triaged \u21D2 this is a redelivery. Do nothing (and pay for nothing).\n if (ticket.category) {\n return Response.json({ skipped: true, reason: 'already triaged', category: ticket.category });\n }\n\n const input = `${ticket.subject ?? ''}\\n\\n${ticket.body ?? ''}`.trim();\n if (!input) return Response.json({ skipped: true, reason: 'empty ticket' });\n\n const verdict = await fetch(`${base}/v1/ai/classify`, {\n method: 'POST',\n headers: { authorization: `Bearer ${ai}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n input,\n labels: LABELS,\n rubric:\n 'billing = money, invoices, refunds or subscriptions. '\n + 'bug = something is broken or behaves incorrectly. '\n + 'how_to = the customer is asking how to do something. '\n + 'other = anything else.',\n }),\n });\n if (!verdict.ok) {\n const detail = await verdict.text();\n return Response.json(\n { error: 'classify_failed', status: verdict.status, detail: detail.slice(0, 300) },\n { status: 502 },\n );\n }\n const v = ((await verdict.json()) as {\n data?: { label?: string; confidence?: number; rationale?: string };\n }).data ?? {};\n const label = LABELS.includes(String(v.label)) ? String(v.label) : 'other';\n\n const patch = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { category: label, state: 'open' } }),\n });\n\n return Response.json({\n item_id: itemId,\n category: label,\n confidence: v.confidence ?? null,\n rationale: v.rationale ?? null,\n written: patch.ok,\n });\n },\n};\n"
16537
17534
  }
16538
17535
  },
17536
+ {
17537
+ "id": "fal-media",
17538
+ "title": "AI Media Studio (fal.ai images \xB7 Veo video \xB7 credits)",
17539
+ "vertical": "ai",
17540
+ "summary": "Generate images and Veo video through fal.ai's queue API with NO vendor adapter \u2014 the generation lane's generic completion options carry fal's webhook in the query string, read its OK/ERROR words and settle on the image list or the video \u2014 while the credits for each render are held up front and refunded automatically when fal fails.",
17541
+ "collections": [
17542
+ "renders"
17543
+ ],
17544
+ "features": [
17545
+ "jobs",
17546
+ "payments",
17547
+ "cms",
17548
+ "functions"
17549
+ ],
17550
+ "hasFunctions": true,
17551
+ "byoKeys": [
17552
+ "fal_key"
17553
+ ],
17554
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// \"AI Media Studio\" \u2014 images and video from fal.ai, with credits that cannot\n// leak. One function starts a render; vxil does the rest:\n//\n// start-render (your function, end-user mode)\n// \u2192 writes a `renders` row the user owns\n// \u2192 POST /v1/jobs/generation: a fal QUEUE job, credits HELD for it\n// vxil's generation lane\n// \u2192 calls fal with your key, the signed callback in `?fal_webhook=`\n// \u2192 fal POSTs back `{ status: 'OK' | 'ERROR', payload }`\n// \u2192 status_map reads OK/ERROR, result_path picks the images / the video\n// \u2192 the `renders` row gets generation_status + result\n// \u2192 credits COMMIT on success, REFUND on failure or timeout\n//\n// No fal adapter exists in vxil and none is needed: the three generic options\n// (`completion.callback.query_param`, `completion.status_map`,\n// `completion.result_path`) describe any \"POST the job, we call your webhook\"\n// vendor. The platform owns the part a function cannot \u2014 the held credits,\n// the timeout and the refund.\n//\n// The credits half is a payments INTEGRATION on the deterministic `mock`\n// provider \u2014 no provider account needed to try it. \"Credits\" are usage units,\n// not money; vxil is never in the flow of funds.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n generation: {\n maxConcurrent: 10,\n // fal images finish in seconds, an 8 s Veo clip in a few minutes \u2014\n // a run fal never calls back about fails (and refunds) at 15 minutes\n defaultTimeoutMs: 900_000,\n maxTimeoutMs: 1_800_000,\n pollMaxAttempts: 30,\n // a video costs 10 credits; one run may never hold more than 20\n maxReserveCredits: 20,\n // \u2026nor may all of this project's in-flight renders together hold more than 2,000\n maxOutstandingReserveCredits: 2_000,\n },\n },\n\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n productMap: {\n media_pack_100: { creditType: 'media_credits', amount: 100, period: 'once' },\n },\n // subscription tiers: a monthly top-up for subscribers\n tierMap: {\n studio: {\n entitlements: ['render'],\n quotas: {},\n rank: 10,\n grants: [{ creditType: 'media_credits', amount: 300, period: 'monthly' }],\n },\n },\n // a render that fails or times out gives its held credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {\n // a render row is live the moment it is written\n draftPublish: false,\n // in end-user mode a collection with no owner is server-only \u2014 renders\n // declares one, so a signed-in user sees only their own renders\n strictEndUserScope: true,\n // Lane-A hook (cms.md \xA77): the dedupe key IS owner + ':' + request_key,\n // server-enforced \u2014 so one user's request_key can never collide with, or\n // block, another user's (a body naming another owner is a 400 anyway).\n hooks: {\n render_dedupe_key: {\n collection: 'renders',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.dedupe_key == concat(item.owner, ':', item.request_key)\",\n message: \"dedupe_key must be owner + ':' + request_key\",\n },\n },\n },\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n renders: {\n singular: 'render',\n ownerField: 'owner',\n fields: {\n // THE DEDUPE ANCHOR \u2014 owner + ':' + the client's own request_key\n // (the hook above enforces the composition), so the key is per user.\n // A retried start (a double tap, a timeout) hits 409 instead of\n // paying twice, and start-render re-drives a start that never got\n // its run.\n dedupe_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n request_key: { type: 'string', required: true },\n owner: { type: 'string', indexSlot: 's2' },\n kind: { type: 'string', required: true, indexSlot: 's3', validation: { enum: ['image', 'video'] } },\n // written by vxil's status mirror: pending \u2192 processing \u2192 completed | failed\n generation_status: { type: 'string', indexSlot: 's4' },\n credits: { type: 'int', indexSlot: 'n1' },\n created_at: { type: 'datetime', indexSlot: 't1' },\n prompt: { type: 'text', required: true },\n run_id: { type: 'text' },\n // written by vxil's status mirror on completion: the value at\n // `result_path` \u2014 fal's `images` list, or its `video` object\n result: { type: 'json' },\n },\n },\n },\n },\n\n functions: {\n // Starts ONE render for the signed-in user. Invoke it in END-USER mode (with\n // the user's session): the held credits are forced onto that user, and the\n // row is theirs.\n 'start-render': {\n entry: './functions/start-render.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'jobs:write'],\n // your fal API key; it rides the provider call's Authorization header\n secrets: ['secret:fal_key'],\n // the function itself never calls fal \u2014 vxil's generation lane does\n egressAllow: [],\n // `vxil gen` types vx.fn['start-render'] from this\n signature: {\n input: { kind: 'string', prompt: 'string', request_key: 'string' },\n output:\n '{ item_id: string; run_id: string; credits: number }'\n + ' | { duplicate: true; request_key: string; item_id: string; run_id: string | null; generation_status: string }',\n },\n },\n },\n\n secrets: {\n fal_key: {\n feature: 'functions',\n description: 'your fal.ai API key (the \"Key \u2026\" credential) \u2014 vxil never holds a fal account of its own',\n },\n },\n});\n",
17555
+ "readme": "# AI Media Studio \u2014 fal.ai images and Veo video, with credits that cannot leak\n\n```bash\nvxil init my-studio --template fal-media\ncd my-studio\nprintf '%s' \"$FAL_KEY\" | vxil secrets set functions/fal_key\nvxil push\n```\n\nOne function starts a render; vxil carries it to the end. You bring a fal.ai key; vxil holds the\nuser's credits while fal works, writes the image or the video onto the user's `renders` row when\nfal calls back, and gives the credits back when fal fails or never answers.\n\n**What this blueprint teaches that the others do not:** a third-party *queue* API \u2014 \"POST the job,\nwe call your webhook\" \u2014 completed through the generation lane's **generic completion options**,\nwith no vendor adapter in vxil and no long call held open in a function:\n\n| option | what it does | fal's case |\n|---|---|---|\n| `completion.callback.query_param` | puts vxil's signed callback URL in the provider URL's query string, and sends `provider.body` exactly as written | fal reads its webhook from `?fal_webhook=` and the model input from the body |\n| `completion.status_map` | up to 8 of the provider's own status words \u2192 `completed` \xB7 `failed` \xB7 `processing` | fal's webhook says `OK` or `ERROR` \u2014 words vxil's built-in list would read as \"still working\" until the run timed out |\n| `completion.result_path` | the value the run settles with, written to your record as `result` | `payload.images` (a list of `{ url, width, height, \u2026 }`) or `payload.video` (`{ url, \u2026 }`) |\n\nThe same three options fit any vendor that calls you back (a transcode API, a render farm, a\nlong-running inference host) \u2014 change the URL, the words and the path.\n\n## What you get\n\n- **`renders`** \u2014 one row per request, owned by the user who started it (`strictEndUserScope`, so a\n signed-in user reads only their own renders). `dedupe_key` (the owner + `:` + the client's\n `request_key`, the composition enforced by a `beforeWrite` hook) is unique, so keys are **per user**: a\n double tap or a retried start is a `409` the function reads back. A row that already has its run is a\n duplicate; a row with no run (the first start died, or lost its enqueue answer, between the row and\n the enqueue) is **re-driven** \u2014 the same `dedupe_key` is the generation run's `idempotency_key`, so\n jobs hands back the existing run and fal is never asked twice.\n- **`start-render`** (http function, end-user mode) \u2014 writes the row, then enqueues ONE generation run:\n fal's queue URL, your key in `Authorization: Key \u2026`, the three options above, a status mirror onto\n the row, and `reserve_credits` for the render's cost (image 1, video 10 `media_credits`).\n- **credits** \u2014 a payments integration on the `mock` provider (no provider account needed). The hold is\n taken *before* the run is queued; a user without the credits gets `402` at once and nothing is held.\n Success commits the hold; a fal `ERROR`, a run fal never calls back about (the timeout: 5 min for an\n image, 15 for a video) or a cancel refunds it (`ledger.autoRefundOnJobFailure`).\n\n## Run it\n\nGive a user some credits from your server (or sell `media_pack_100` through your payments provider):\n\n```bash\ncurl -s -X POST \"https://api.vxil.com/v1/payments/credits/grant\" \\\n -H \"authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n -H 'idempotency-key: welcome-u1' \\\n -d '{\"user_id\":\"<the user id>\",\"credit_type\":\"media_credits\",\"amount\":25,\"source\":\"welcome\"}'\n```\n\nStart a render **with the user's session** (end-user mode \u2014 the held credits are forced onto that\nuser, and a function cannot hold credits for anyone else):\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// after `vxil gen`, vx.fn['start-render'] is typed from the function's declared signature\nconst vx = new Vxil({ apiKey: process.env.VXIL_PUBLISHABLE_KEY!, endUserToken: process.env.USER_SESSION! });\nconst started = await vx.fn['start-render']({ kind: 'image', prompt: 'a red fox in the snow', request_key: 'fox-1' });\n// \u2192 { item_id, run_id, credits: 1 }\n// (or { duplicate: true, request_key, item_id, run_id, generation_status } on a retry)\n```\n\nThen read the row \u2014 or subscribe to its changes \u2014 until `generation_status` is `completed`:\n\n```ts\nif ('item_id' in started) {\n const row = await vx.from('renders').get(started.item_id);\n // row.generation_status \u2192 'completed'\n // row.result \u2192 [{ url: 'https://fal.media/files/\u2026png', width: 1024, height: 1024, \u2026 }]\n}\n```\n\nEvery run ends with `job.generation.completed` or `job.generation.failed` (`generation_id` = the row's\n`item_id`, `correlation_id` = its `request_key`), so a function bound to `job.generation.` can react \u2014\nsend a push, thumbnail the image \u2014 without a lookup table of its own.\n\n## How it fails, and what the user sees\n\n| what happened | the run | the row | the credits |\n|---|---|---|---|\n| fal answered `OK` | `completed` | `generation_status: completed`, `result` set | committed |\n| fal answered `ERROR` (its `error` text is the run's `last_error_msg`; `job.generation.failed` carries `error_class: 'ProviderFailed'`) | `failed` | `generation_status: failed` | refunded |\n| fal never called back | `failed` (`GenerationExpired`) at the timeout | `failed` | refunded |\n| the start call to fal failed with a 5xx / network fault | retried with backoff; terminal after the attempts | `processing` \u2192 the outcome above | held until then |\n| the user had too few credits | ended at once (`ReserveInsufficient`), never queued | `failed` (the function marks it); that `request_key` is spent \u2014 retry after a top-up with a new one | nothing held |\n| too many renders in flight, or a jobs-side fault | not created (the function answers the caller `429` with the wait in `retry_after`, or `502`) | `pending`, no run \u2014 calling start-render again with the **same** `request_key` re-drives it | nothing held |\n\n## Evidence\n\n- **Executed** (vxil's own int tests, `workers/jobs-v1/src/generation-completion-options.int.test.ts`):\n the `?fal_webhook=` callback URL is signed and verifies, the provider receives exactly the model input,\n `OK` settles completed with `payload.images` mirrored as `result` and the hold committed, `ERROR`\n settles failed with the hold refunded, a progress ping stays processing.\n- **Read in fal's documentation, not executed against fal from vxil** \u2014 the queue base\n `https://queue.fal.run/<model>`, the `fal_webhook` query parameter, the webhook body\n `{ request_id, status: 'OK' | 'ERROR', payload, error }`, and the model ids `fal-ai/flux/dev` and\n `fal-ai/veo3` with their inputs. Check the model page for each model's exact input fields before you\n ship, and keep `num_images` / `duration` to what your model accepts.\n\n## Your key, and where it lives\n\n`fal_key` is a function secret; `start-render` reads it at invoke time and puts it in the generation\nrun's provider headers, which vxil stores with the run (your project only) for as long as the jobs\nretention keeps the run, and sends to fal on the start call. It is **never returned by a read**:\n`GET /v1/jobs/runs/{run_id}` (and the dashboard and MCP reads built on it) shows the header names of a\ngeneration run with every value as `[redacted]`. Rotate it with `vxil secrets set functions/fal_key`;\nruns already queued keep the key they were started with.\n",
17556
+ "functions": {
17557
+ "start-render.ts": "// start-render.ts \u2014 start ONE fal.ai render for the signed-in user (a vxil\n// function, http trigger, END-USER mode).\n//\n// POST /v1/fn/start-render (with the user's session)\n// { \"kind\": \"image\" | \"video\", \"prompt\": \"\u2026\", \"request_key\": \"<your idempotency key>\" }\n// \u2192 202 { item_id, run_id, credits } a new render, credits held\n// \u2192 200 { duplicate: true, request_key, item_id, run_id, generation_status }\n// this user's request_key already started one\n//\n// What happens after the 202 is vxil's, not this function's:\n// \u2022 the generation lane POSTs fal's QUEUE API with your key and the signed\n// callback URL in `?fal_webhook=` (completion.callback.query_param);\n// \u2022 fal calls back `{ status: 'OK' | 'ERROR', payload, error? }` \u2014 status_map\n// turns its words into completed / failed;\n// \u2022 result_path picks `payload.images` (or `payload.video`), and the status\n// mirror writes it onto this render's `result` with generation_status;\n// \u2022 the held credits commit on completed and are REFUNDED on failed, on a\n// run fal never calls back about (the timeout), and on a cancel.\n// Every run ends with job.generation.completed or job.generation.failed\n// (generation_id = the render's item_id, correlation_id = its request_key) if\n// you want to react to it in another function.\n//\n// DELIVERY IS AT-LEAST-ONCE and users double-tap: the row's `dedupe_key`\n// (owner + ':' + request_key \u2014 PER USER, so one user's key can never block\n// another's) is declared unique, so a second start with the same key is a\n// 409. On a 409 we read THIS user's row: a row that already has its run is a\n// duplicate; a row with no run (the first start died, or its enqueue answer\n// was lost, between the row and the enqueue) is RE-DRIVEN \u2014 the generation\n// run carries the same dedupe_key as its idempotency_key, so jobs hands back\n// the existing run instead of enqueueing a second fal job.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Input = { kind?: string; prompt?: string; request_key?: string };\ntype Env = HttpFunctionEnvelope<Input>;\ntype RenderRow = { item_id: string; data: { kind?: string; prompt?: string; run_id?: string; generation_status?: string } };\n\n/** fal model ids (https://fal.ai/models) \u2014 swap freely; both are queue APIs. */\nconst MODELS = {\n image: { url: 'https://queue.fal.run/fal-ai/flux/dev', credits: 1, resultPath: 'payload.images' },\n video: { url: 'https://queue.fal.run/fal-ai/veo3', credits: 10, resultPath: 'payload.video' },\n} as const;\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 const jobs = env.scoped_jwts?.jobs;\n const falKey = env.secrets?.fal_key;\n if (!cms || !jobs) return Response.json({ error: 'missing cms/jobs scope' }, { status: 403 });\n if (!falKey) return Response.json({ error: 'store your fal key: vxil secrets set functions/fal_key' }, { status: 500 });\n // the held credits are FORCED onto the verified end-user \u2014 a function\n // cannot hold credits against a user it does not act for\n const user = env.end_user?.id;\n if (!user) return Response.json({ error: 'invoke start-render with the user\\'s session (end-user mode)' }, { status: 401 });\n\n const kind = env.payload?.kind === 'video' ? 'video' : env.payload?.kind === 'image' ? 'image' : null;\n const prompt = typeof env.payload?.prompt === 'string' ? env.payload.prompt.trim().slice(0, 2_000) : '';\n const requestKey = typeof env.payload?.request_key === 'string' ? env.payload.request_key.slice(0, 120) : '';\n if (!kind || !prompt || !requestKey) {\n return Response.json({ error: 'need { kind: image|video, prompt, request_key }' }, { status: 422 });\n }\n const dedupeKey = `${user}:${requestKey}`;\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n // 1. the render row (owned by the user \u2014 cms forces `owner` in end-user mode;\n // the render_dedupe_key hook checks dedupe_key = owner + ':' + request_key)\n const created = await fetch(`${base}/v1/cms/items/renders`, {\n method: 'POST',\n headers: H,\n body: JSON.stringify({\n data: {\n dedupe_key: dedupeKey, request_key: requestKey, owner: user, kind, prompt,\n credits: MODELS[kind].credits, generation_status: 'pending', created_at: new Date().toISOString(),\n },\n }),\n });\n if (created.status === 409) {\n // THIS user's row for this key (the read is owner-scoped in end-user mode)\n const filter = encodeURIComponent(JSON.stringify({ dedupe_key: dedupeKey }));\n const found = await fetch(`${base}/v1/cms/items/renders?filter=${filter}&limit=1`, { headers: H });\n const row = found.ok ? ((await found.json()) as { data?: { items?: RenderRow[] } }).data?.items?.[0] : undefined;\n if (!row) return Response.json({ error: `render lookup: ${found.status}` }, { status: 502 });\n if (row.data.run_id || row.data.generation_status === 'failed') {\n return Response.json({\n duplicate: true, request_key: requestKey, item_id: row.item_id,\n run_id: row.data.run_id ?? null, generation_status: row.data.generation_status ?? 'pending',\n });\n }\n // a start that never got its run: re-drive it (jobs dedupes on dedupe_key)\n const rowKind = row.data.kind === 'video' ? 'video' : 'image';\n return startRun(base, H, jobs, falKey, user, dedupeKey, requestKey, row.item_id, rowKind, row.data.prompt ?? prompt);\n }\n if (!created.ok) return Response.json({ error: `render row: ${created.status}` }, { status: 502 });\n const itemId = ((await created.json()) as { data: { item_id: string } }).data.item_id;\n return startRun(base, H, jobs, falKey, user, dedupeKey, requestKey, itemId, kind, prompt);\n },\n};\n\n/** 2. the fal queue job, babysat by vxil, credits held for it. Idempotent on\n * `dedupeKey`: a re-drive gets the run that already exists. */\nasync function startRun(\n base: string, H: Record<string, string>, jobs: string, falKey: string, user: string,\n dedupeKey: string, requestKey: string, itemId: string, kind: 'image' | 'video', prompt: string,\n): Promise<Response> {\n const model = MODELS[kind];\n const enq = await fetch(`${base}/v1/jobs/generation`, {\n method: 'POST',\n headers: { authorization: `Bearer ${jobs}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n job_name: `fal.${kind}`,\n // the body is EXACTLY fal's model input (query-string callbacks send\n // provider.body as-is \u2014 no payload / callback_url keys merged in)\n provider: {\n url: model.url,\n method: 'POST',\n headers: { authorization: `Key ${falKey}` },\n body: kind === 'image' ? { prompt, num_images: 1 } : { prompt, duration: '8s' },\n },\n completion: {\n mode: 'webhook',\n status_path: 'status',\n callback: { query_param: 'fal_webhook' },\n status_map: { OK: 'completed', ERROR: 'failed' },\n result_path: model.resultPath,\n },\n status_mirror: { feature: 'cms', collection: 'renders', record_id: itemId, column: 'generation_status' },\n reserve_credits: { amount: model.credits, user_id: user, credit_type: 'media_credits', reason: `fal ${kind}` },\n timeout: { after_ms: kind === 'image' ? 300_000 : 900_000 },\n // rides job.generation.* as generation_id / correlation_id\n payload: { generation_id: itemId, correlation_id: requestKey },\n idempotency_key: dedupeKey,\n }),\n });\n if (enq.status === 402) {\n // not enough credits: the run already ENDED (job.generation.failed,\n // ReserveInsufficient) and nothing was held \u2014 mark the row and say so.\n // This key is spent; a new attempt (after a top-up) uses a new request_key.\n await markFailed(base, H, itemId);\n return Response.json({ error: 'insufficient_credits', item_id: itemId }, { status: 402 });\n }\n if (!enq.ok) {\n // 429 (too many in flight) / 5xx: NO run is promised \u2014 leave the row\n // pending with no run, so a retry with the SAME request_key re-drives it\n return Response.json(\n { error: `generation enqueue: ${enq.status}`, item_id: itemId, retry_after: enq.headers.get('retry-after') },\n { status: enq.status === 429 ? 429 : 502 },\n );\n }\n const runId = ((await enq.json()) as { data: { run_id: string } }).data.run_id;\n await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: H,\n body: JSON.stringify({ data: { run_id: runId } }),\n });\n return Response.json({ item_id: itemId, run_id: runId, credits: model.credits }, { status: 202 });\n}\n\nasync function markFailed(base: string, H: Record<string, string>, itemId: string): Promise<void> {\n await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: H,\n body: JSON.stringify({ data: { generation_status: 'failed' } }),\n });\n}\n"
17558
+ }
17559
+ },
16539
17560
  {
16540
17561
  "id": "storefront",
16541
17562
  "title": "Storefront / E-commerce",
@@ -16777,7 +17798,8 @@ export default defineConfig({
16777
17798
  'abandoned-cart': {
16778
17799
  entry: './functions/abandoned-cart.ts',
16779
17800
  trigger: { kind: 'cron', schedule: '0 * * * *' },
16780
- scopes: ['cms:read', 'notifications:send'],
17801
+ // cms:write \u2014 the sweep PATCHes each nudged cart to \`abandoned\` (out of the filter)
17802
+ scopes: ['cms:read', 'cms:write', 'notifications:send'],
16781
17803
  },
16782
17804
  },
16783
17805
 
@@ -16800,9 +17822,9 @@ export default defineConfig({
16800
17822
  },
16801
17823
  });
16802
17824
  `,
16803
- "readme": "# Storefront / E-commerce template\n\nA single-seller e-commerce backend \u2014 catalog, variants, owner-scoped carts, coupons, moderated\nreviews, and an exactly-once checkout \u2014 declared in one typed `vxil.config.ts` plus four functions.\nPayments run as a payments integration with **your own Stripe account**: vxil is never in the flow of funds.\n\n**What it provisions:**\n- `products` / `variants` \u2014 the catalog; `variants.stock` is the oversell-safe inventory counter;\n `variants.price_ref` holds the provider price id (e.g. a Stripe Price) the hosted checkout charges by.\n Both are **`public: true`** \u2014 the shopfront (grid + product-detail variants) reads keyless (see below);\n the transactional collections (`carts`/`orders`/`coupons`) are **not** public.\n- `carts` / `cart_items` \u2014 owner-scoped carts per shopper (guest checkout via anonymous auth);\n each line snapshots `unit_price_cents` + `price_ref` at add-to-cart.\n- `orders` \u2014 unique `number` + unique `cart_ref`, with a state-machine hook guarding status transitions.\n- `coupons` / `coupon_redemptions` \u2014 codes plus one row per redemption; cap redemption writes\n with a write-body `guards[]` under one `lock` (the cms \xA710 pattern \u2014 shown in the config, not wired into `checkout.ts`).\n- `reviews` \u2014 owner-scoped, 1\u20135 rating enforced by a hook, moderated via draft/publish.\n- Features: `cms`, `auth`, `payments` (Stripe, BYO keys), `notifications`, `functions`; four functions \u2014\n `checkout` (the saga), `price-cart` (pricing engine), `on-order-paid` (receipt + fulfillment webhook),\n `abandoned-cart` (hourly cron nudge).\n\n**Apply it:**\n\n```bash\nvxil init --template storefront\nvxil quickstart\nvxil secrets set stripe_secret && vxil secrets set stripe_webhook\nvxil push\nvxil seed # demo products + the WELCOME10 coupon\nvxil gen\n```\n\n**What to learn from this:**\n1. **The checkout saga** (`functions/checkout.ts`) \u2014 reserve inventory \u2192 create the order \u2192 open the\n payment, compensating on any failure. All-or-nothing across features, no cross-feature ACID needed.\n Invoke `checkout`/`price-cart` from YOUR backend (server key): with `strictEndUserScope` on, an\n end-user-mode invocation is correctly denied on the shared collections they touch\n (`cart_items`/`variants`/`coupons`) \u2014 inventory is tenant-wide by design.\n2. **Oversell-safety** \u2014 `variants.stock` with `validation: { min: 0 }` makes `PATCH { $inc: { stock: -qty } }`\n a single-statement conditional decrement: exactly one winner under N concurrent buyers.\n3. **Exactly-once placement** \u2014 `orders.cart_ref` is declared `unique: true` in the config (carried by\n `vxil push`, `cms.md` \xA79.3), so N racing checkouts of one cart yield exactly one order \u2014 the rest see\n the `409 unique_violation` and `checkout.ts` answers `already_placed` (idempotent).\n\n```typescript\n// browse the live catalog with a server key (typed SDK)\nconst { items } = await vx.from('products').query({\n filter: { $status: 'published' },\n sort: '-price_cents',\n limit: 25,\n});\n```\n\n4. **The public shopfront \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). `products` and `variants` are\n `public: true`, so a static/JAMstack storefront renders the grid and product-detail pages with **no API\n key** \u2014 the edge forces `status = 'published'` and edge-caches the response. Only the write path (cart,\n checkout, admin) needs a key; the transactional collections are never public.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public grid \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'products', { sort: '-price_cents', limit: 24 });\n// a product's sellable variants (price, availability, provider price_ref)\nconst variants = await listCmsPublic('ten_your_tenant_id', 'variants', { filter: { product: productId } });\n```\n\n**Reader UI (optional):** drop [`@vxil/react/comments`](../../packages/react) onto a product page\nfor the `reviews` thread + a file uploader (enable the `comments` feature) \u2014 a pure client-side component\nover the shipped API. It installs via npm into a bundled app (no served `.mjs`).\n\n**Go deeper:** [`examples/ecommerce`](../../examples/ecommerce) is the fully-annotated deep version of this\nblueprint; vxil.com/docs/guide/04-data-with-cms (**public delivery**), vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (`$inc`, `lock`/`guards[]` on the item write routes),\nvxil.com/docs/guide/06-feature-catalog (payments),\nvxil.com/docs/guide/08-running-your-code-functions, and `templates/catalog/` (a content-only\npublic product grid).\n",
17825
+ "readme": "# Storefront / E-commerce template\n\nA single-seller e-commerce backend \u2014 catalog, variants, owner-scoped carts, coupons, moderated\nreviews, and an exactly-once checkout \u2014 declared in one typed `vxil.config.ts` plus four functions.\nPayments run as a payments integration with **your own Stripe account**: vxil is never in the flow of funds.\n\n**What it provisions:**\n- `products` / `variants` \u2014 the catalog; `variants.stock` is the oversell-safe inventory counter;\n `variants.price_ref` holds the provider price id (e.g. a Stripe Price) the hosted checkout charges by.\n Both are **`public: true`** \u2014 the shopfront (grid + product-detail variants) reads keyless (see below);\n the transactional collections (`carts`/`orders`/`coupons`) are **not** public.\n- `carts` / `cart_items` \u2014 owner-scoped carts per shopper (guest checkout via anonymous auth);\n each line snapshots `unit_price_cents` + `price_ref` at add-to-cart.\n- `orders` \u2014 unique `number` + unique `cart_ref`, with a state-machine hook guarding status transitions.\n- `coupons` / `coupon_redemptions` \u2014 codes plus one row per redemption; cap redemption writes\n with a write-body `guards[]` under one `lock` (the cms \xA710 pattern \u2014 shown in the config, not wired into `checkout.ts`).\n- `reviews` \u2014 owner-scoped, 1\u20135 rating enforced by a hook, moderated via draft/publish.\n- Features: `cms`, `auth`, `payments` (Stripe, BYO keys), `notifications`, `functions`; four functions \u2014\n `checkout` (the saga), `price-cart` (pricing engine), `on-order-paid` (receipt + fulfillment webhook),\n `abandoned-cart` (hourly cron nudge \u2014 one per abandonment: a nudged cart is marked `abandoned`).\n\n**Apply it:**\n\n```bash\nvxil init --template storefront\nvxil quickstart\nvxil secrets set stripe_secret && vxil secrets set stripe_webhook\nvxil push\nvxil seed # demo products + the WELCOME10 coupon\nvxil gen\n```\n\n**What to learn from this:**\n1. **The checkout saga** (`functions/checkout.ts`) \u2014 reserve inventory \u2192 create the order \u2192 open the\n payment, compensating on any failure. All-or-nothing across features, no cross-feature ACID needed.\n Invoke `checkout`/`price-cart` from YOUR backend (server key): with `strictEndUserScope` on, an\n end-user-mode invocation is correctly denied on the shared collections they touch\n (`cart_items`/`variants`/`coupons`) \u2014 inventory is tenant-wide by design.\n2. **Oversell-safety** \u2014 `variants.stock` with `validation: { min: 0 }` makes `PATCH { $inc: { stock: -qty } }`\n a single-statement conditional decrement: exactly one winner under N concurrent buyers.\n3. **Exactly-once placement** \u2014 `orders.cart_ref` is declared `unique: true` in the config (carried by\n `vxil push`, `cms.md` \xA79.3), so N racing checkouts of one cart yield exactly one order \u2014 the rest see\n the `409 unique_violation` and `checkout.ts` answers `already_placed` (idempotent).\n\n```typescript\n// browse the live catalog with a server key (typed SDK)\nconst { items } = await vx.from('products').query({\n filter: { $status: 'published' },\n sort: '-price_cents',\n limit: 25,\n});\n```\n\n4. **The public shopfront \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). `products` and `variants` are\n `public: true`, so a static/JAMstack storefront renders the grid and product-detail pages with **no API\n key** \u2014 the edge forces `status = 'published'` and edge-caches the response. Only the write path (cart,\n checkout, admin) needs a key; the transactional collections are never public.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public grid \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'products', { sort: '-price_cents', limit: 24 });\n// a product's sellable variants (price, availability, provider price_ref)\nconst variants = await listCmsPublic('ten_your_tenant_id', 'variants', { filter: { product: productId } });\n```\n\n**Reader UI (optional):** drop [`@vxil/react/comments`](../../packages/react) onto a product page\nfor the `reviews` thread + a file uploader (enable the `comments` feature) \u2014 a pure client-side component\nover the shipped API. It installs via npm into a bundled app (no served `.mjs`).\n\n**Go deeper:** [`examples/ecommerce`](../../examples/ecommerce) is the fully-annotated deep version of this\nblueprint; vxil.com/docs/guide/04-data-with-cms (**public delivery**), vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (`$inc`, `lock`/`guards[]` on the item write routes),\nvxil.com/docs/guide/06-feature-catalog (payments),\nvxil.com/docs/guide/08-running-your-code-functions, and `templates/catalog/` (a content-only\npublic product grid).\n",
16804
17826
  "functions": {
16805
- "abandoned-cart.ts": "// abandoned-cart.ts \u2014 RETENTION CRON (a vxil function, \xA77.3).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep open carts that went stale (last_activity\n// older than 1h) using the slot-indexed range filter, and nudge the shopper. This is the\n// jobs-cron pattern \u2014 no new primitive, just a scheduled function.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Cart { status: string; last_activity: string; end_user?: 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 const notif = env.scoped_jwts?.notifications;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // carts still `open` whose last_activity is > 1h ago (t1 range filter, index-served)\n const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString();\n const filter = enc({ status: 'open', last_activity: { $lt: cutoff } });\n const res = await fetch(`${base}/v1/cms/items/carts?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: Cart }[] } };\n const carts = body.data?.items ?? [];\n\n // POST /v1/notifications/send is { user_id, template, data } \u2014 `transactional` is the\n // shipped generic template (requires data.subject + data.paragraph, notifications.md \xA77).\n let nudged = 0;\n for (const c of carts) {\n if (!notif || !c.data.end_user) continue;\n await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${notif}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_id: c.data.end_user,\n template: 'transactional',\n data: {\n subject: 'You left items in your cart',\n paragraph: `Your cart (${c.item_id}) is still waiting \u2014 come back and finish checkout any time.`,\n },\n }),\n });\n nudged++;\n }\n return Response.json({ scanned: carts.length, nudged });\n },\n};\n\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n",
17827
+ "abandoned-cart.ts": "// abandoned-cart.ts \u2014 RETENTION CRON (a vxil function, \xA77.3).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep open carts that went stale (last_activity\n// older than 1h) using the slot-indexed range filter, and nudge the shopper. This is the\n// jobs-cron pattern \u2014 no new primitive, just a scheduled function.\n//\n// ONE nudge per abandonment: after the send, the cart is marked `abandoned`, which takes\n// it out of the `status: 'open'` filter \u2014 so the next tick reads the NEXT stale carts\n// instead of the same 100 again (and a shopper is not nudged every hour forever). Your\n// app sets `status` back to `open` (with a fresh `last_activity`) when the shopper\n// touches the cart again; a later abandonment is nudged again. The Idempotency-Key\n// (cart id + last_activity) keeps a redelivered tick, or a failed mark, from\n// sending the same nudge twice.\n\n// cron-walk: drains-filter \u2014 a nudged cart is marked `abandoned` and leaves the `status: 'open'` filter.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Cart { status: string; last_activity: string; end_user?: 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 const notif = env.scoped_jwts?.notifications;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // carts still `open` whose last_activity is > 1h ago (t1 range filter, index-served)\n const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString();\n const filter = enc({ status: 'open', last_activity: { $lt: cutoff } });\n const res = await fetch(`${base}/v1/cms/items/carts?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ error: `query ${res.status}` }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: Cart }[] } };\n const carts = body.data?.items ?? [];\n\n // POST /v1/notifications/send is { user_id, template, data } \u2014 `transactional` is the\n // shipped generic template (requires data.subject + data.paragraph, notifications.md \xA77).\n let nudged = 0;\n let markFailed = 0;\n for (const c of carts) {\n if (!notif || !c.data.end_user) continue;\n const sent = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n 'idempotency-key': `abandoned-cart:${c.item_id}:${c.data.last_activity}`,\n },\n body: JSON.stringify({\n user_id: c.data.end_user,\n template: 'transactional',\n data: {\n subject: 'You left items in your cart',\n paragraph: `Your cart (${c.item_id}) is still waiting \u2014 come back and finish checkout any time.`,\n },\n }),\n }).then((r) => r.ok).catch(() => false);\n if (!sent) continue; // left open: the next tick tries again (same Idempotency-Key)\n nudged++;\n // Out of the filter: the next tick reads the next stale carts, not these again.\n // The mark needs `cms:write` (declared in vxil.config.ts). A refused mark is\n // COUNTED, never swallowed: the cart stays open, so the next tick re-reads it\n // (the Idempotency-Key keeps the nudge from going out twice) \u2014 and a run that\n // reports `mark_failed > 0` answers 500, so it is SEEN (`functions.run.failed`).\n const marked = await fetch(`${base}/v1/cms/items/carts/${c.item_id}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { status: 'abandoned' } }),\n }).then((r) => r.ok).catch(() => false);\n if (!marked) markFailed++;\n }\n return Response.json({ scanned: carts.length, nudged, mark_failed: markFailed }, { status: markFailed > 0 ? 500 : 200 });\n },\n};\n\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n",
16806
17828
  "checkout.ts": "// checkout.ts \u2014 THE CHECKOUT SAGA (a vxil function, \xA77.3).\n//\n// The hard part of e-commerce: \"reserve N SKUs + capture payment + create the order,\n// all-or-nothing\" \u2014 which is the deliberately-REJECTED cross-feature-ACID case. The\n// doctrinal (and incumbent-identical) answer is a reserve\u2192settle\u2192reverse SAGA, and it\n// is exactly-once under any concurrency. Shopify+Stripe do the same thing (Stripe is a\n// physically separate system reconciled by webhook); nothing here is a platform gap.\n//\n// Invoke it SERVER-SIDE (your backend POSTs /v1/fn/checkout with a server key):\n// under cms.strictEndUserScope an end-user-mode invocation is correctly denied on\n// the shared collections this saga touches (cart_items/variants) \u2014 inventory is a\n// tenant-wide surface, so the reserve step is server work by design.\n//\n// Steps:\n// 1. read the cart (owner + currency) + its lines (cms:read)\n// 2. RESERVE each line: PATCH variant {$inc:{stock:-qty}} \u2014 validation.min:0 makes it a\n// single-statement oversell-safe decrement (409 inc_out_of_bounds if insufficient).\n// On any failure \u2192 COMPENSATE (re-$inc the ones already reserved) \u2192 409 out_of_stock.\n// 3. create the ORDER with a unique cart_ref \u2192 EXACTLY-ONCE (409 on a racing duplicate).\n// 4. open a payments checkout-session (mode:payment, Idempotency-Key = order number).\n// 5. return { order_id, checkout_url }. Capture completes async \u2192 functions/on-order-paid.ts.\n// (Not shipped here: if payment never completes, schedule a jobs `deliver_after`\n// release that re-$inc's the reserve.)\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload`\ntype Env = HttpFunctionEnvelope<{ cart_id?: string; success_url?: string; cancel_url?: string }>;\ninterface Line { variant: string; qty: number; unit_price_cents: number; price_ref: 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 const pay = env.scoped_jwts?.payments;\n if (!cms || !pay) return json({ error: 'missing cms/payments scope' }, 403);\n const { cart_id, success_url, cancel_url } = env.payload ?? {};\n if (!cart_id) return json({ error: 'cart_id required' }, 400);\n\n const H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n\n // 1. read the cart (its end_user owner + currency), then its lines\n const cartRes = await fetch(`${base}/v1/cms/items/carts/${cart_id}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!cartRes.ok) return json({ error: 'cart_not_found' }, 404);\n const cart = ((await cartRes.json()) as { data?: { data?: { end_user?: string; currency?: string } } }).data?.data ?? {};\n const shopper = env.end_user?.id ?? cart.end_user;\n if (!shopper) return json({ error: 'cart has no owner (end_user)' }, 400);\n const lines = await get<Line>(`${base}/v1/cms/items/cart_items?filter=${enc({ cart: cart_id })}&limit=100`, cms);\n if (lines.length === 0) return json({ error: 'empty cart' }, 400);\n\n // 2. RESERVE inventory line-by-line (oversell-safe $inc). Track for compensation.\n const reserved: Line[] = [];\n for (const ln of lines) {\n const r = await fetch(`${base}/v1/cms/items/variants/${ln.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: -ln.qty } }),\n });\n if (!r.ok) {\n // compensate everything reserved so far, then fail cleanly\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'out_of_stock', variant: ln.variant }, 409);\n }\n reserved.push(ln);\n }\n\n // 3. create the ORDER \u2014 unique cart_ref makes placement exactly-once under concurrency.\n const total = lines.reduce((s, l) => s + l.unit_price_cents * l.qty, 0);\n const number = `ORD-${cart_id.slice(0, 8)}`;\n const orderRes = await fetch(`${base}/v1/cms/items/orders`, {\n method: 'POST', headers: H(cms),\n body: JSON.stringify({\n data: {\n number, cart_ref: cart_id, status: 'pending', end_user: shopper,\n total_cents: total, placed_at: new Date().toISOString(), lines,\n },\n }),\n });\n if (orderRes.status === 409) {\n // a concurrent checkout already placed this cart \u2192 idempotent: report it placed\n return json({ status: 'already_placed', number }, 200);\n }\n if (!orderRes.ok) {\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'order_create_failed' }, 502);\n }\n const order = (await orderRes.json()) as { data?: { item_id?: string } };\n\n // 4. open the hosted payment (one-time). Idempotency-Key = order number \u21D2 safe to retry.\n // The documented checkout-sessions contract (payments.md \xA73): user_id + line_items\n // [{ price_ref, quantity, amount_cents?, currency? }] + mode + success/cancel URLs.\n // price_ref is the PROVIDER's price id (a Stripe Price) snapshot on the cart line \u2014\n // Stripe's adapter charges by price id; amount_cents/currency serve amount-based\n // providers (PayPal payment mode). NOTE: the shipped Stripe adapter charges\n // line_items[0] only \u2014 for multi-line carts on Stripe, collapse to one provider\n // line (or one order-total price) before opening the session.\n const currency = cart.currency ?? 'usd';\n const sess = await fetch(`${base}/v1/payments/checkout-sessions`, {\n method: 'POST',\n headers: { ...H(pay), 'idempotency-key': number },\n body: JSON.stringify({\n user_id: shopper,\n mode: 'payment',\n line_items: lines.map((l) => ({ price_ref: l.price_ref, quantity: l.qty, amount_cents: l.unit_price_cents, currency })),\n success_url: success_url ?? 'https://storefront.example/checkout/success',\n cancel_url: cancel_url ?? 'https://storefront.example/checkout/cancel',\n }),\n });\n if (!sess.ok) {\n // the order stays placed (pending) \u2014 surface the payment error so the caller can\n // retry the session (same Idempotency-Key) after fixing price_refs / provider keys.\n return json({ order_id: order.data?.item_id, number, error: 'payment_session_failed' }, 502);\n }\n const s = (await sess.json()) as { data?: { url?: string } };\n\n return json({ order_id: order.data?.item_id, number, checkout_url: s.data?.url }, 201);\n },\n};\n\n// \u2500\u2500 tiny helpers (the vxil REST envelope is { data: { items }, meta }; items carry item_id) \u2500\u2500\nasync function get<T>(url: string, jwt: string): Promise<T[]> {\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: T }[] } };\n return (body.data?.items ?? []).map((i) => ({ id: i.item_id, ...i.data } as T));\n}\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
16807
17829
  "on-order-paid.ts": "// on-order-paid.ts \u2014 SETTLEMENT SIDE-EFFECTS (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.updated for `orders`. When the order flips to\n// `paid` \u2014 YOUR payment-success handler PATCHes it (e.g. a function subscribed to the\n// payments `payments.charge.succeeded` event via a webhooks-out subscription on the\n// `payments.` prefix, or your backend after the hosted checkout returns); the\n// order_transition hook validates the flip \u2014 fan out the side-effects:\n// email the receipt (notifications) and POST the fulfillment webhook to the tenant's\n// 3PL/warehouse over the egress allowlist. Delivery is at-least-once with retry/DLQ \u2014\n// identical semantics to Shopify Flow / a Stripe webhook fan-out.\n//\n// The cms-hook payload is { event, collection, item_id } \u2014 NOT the row \u2014 so the\n// function RE-FETCHES the order by id (through the edge, tenant-scoped). notifications:send\n// is a legitimate function scope (allowed by the deploy; https://vxil.com/docs/guide/08-running-your-code-functions).\n//\n// (Inventory was already reserved atomically at checkout, so there is no decrement here \u2014\n// the reservation simply becomes permanent. A payment FAILURE path compensates instead.)\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CmsHookFunctionEnvelope;\ninterface OrderData { number?: string; status?: string; total_cents?: number; end_user?: string }\ninterface Item { data?: { data?: OrderData } }\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 const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (functions.md \xA73); this guard stays as belt-and-braces.\n if (env.payload?.collection !== 'orders' || !cms || !itemId) return Response.json({ skipped: true });\n\n // Re-fetch the order (the payload carries only the id) and act only on pending\u2192paid.\n const res = await fetch(`${base}/v1/cms/items/orders/${itemId}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const order = ((await res.json()) as Item).data?.data ?? {};\n if (order.status !== 'paid') return Response.json({ skipped: true, status: order.status });\n\n // 1. receipt email (in-app inbox + email via the configured provider).\n if (notif && order.end_user) {\n await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${notif}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_id: order.end_user,\n template: 'transactional',\n data: { subject: `Receipt for order ${order.number}`, paragraph: `Thanks! Your order ${order.number} totalling ${order.total_cents} cents is confirmed.` },\n }),\n }).catch(() => { /* the jobs/webhooks retry+DLQ engine owns durability */ });\n }\n\n // 2. fulfillment webhook to the tenant's warehouse (egress-guarded to fulfillment.example.com).\n await fetch('https://fulfillment.example.com/orders', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ number: order.number, total_cents: order.total_cents }),\n }).catch(() => { /* best-effort here */ });\n\n return Response.json({ settled: order.number });\n },\n};\n",
16808
17830
  "price-cart.ts": "// price-cart.ts \u2014 THE PRICING ENGINE (a vxil function, \xA77.3).\n//\n// This is the module people assume needs a \"promotions feature\". It does NOT \u2014 and it\n// deliberately is NOT a cms lifecycle hook: hooks are single-row and cross-row aggregation\n// is forbidden by design (hooks.ts), so a hook can't sum a cart, apply BOGO across items,\n// or evaluate cart-level thresholds. That is arbitrary domain logic \u2192 a FUNCTION with full\n// JS expressiveness (exactly how Shopify Functions / Scripts run tenant discount code) \u2192 [B].\n//\n// It reads the cart lines + coupon (cms:read) and returns the priced cart. Like checkout,\n// invoke it SERVER-SIDE: under cms.strictEndUserScope the shared collections it reads\n// (cart_items/coupons) are correctly denied to an end-user-mode invocation. checkout.ts\n// recomputes its total from the same server-held snapshots \u2014 never trust a client total;\n// to honor promotions at capture time, apply this function's output there the same way.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload`\ntype Env = HttpFunctionEnvelope<{ cart_id?: string; coupon_code?: string }>;\ninterface Line { variant: string; qty: number; unit_price_cents: number }\ninterface Coupon { code: string; kind: string; value: number; max_uses: number }\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 { cart_id, coupon_code } = env.payload ?? {};\n if (!cart_id) return Response.json({ error: 'cart_id required' }, { status: 400 });\n\n const lines = await get<Line>(`${base}/v1/cms/items/cart_items?filter=${enc({ cart: cart_id })}&limit=100`, cms);\n\n // subtotal (cross-row sum \u2014 the thing a hook can't do)\n const subtotal = lines.reduce((s, l) => s + l.unit_price_cents * l.qty, 0);\n\n // \u2500\u2500 arbitrary promotion rules, plain JS \u2500\u2500\n let discount = 0;\n const applied: string[] = [];\n\n // BOGO on any 2+ identical lines: cheapest unit free per pair\n for (const l of lines) {\n if (l.qty >= 2) { discount += Math.floor(l.qty / 2) * l.unit_price_cents; applied.push('bogo'); }\n }\n\n // tiered cart threshold: 5% over $100, 10% over $250\n if (subtotal >= 25000) { discount += Math.round(subtotal * 0.10); applied.push('tier-10'); }\n else if (subtotal >= 10000) { discount += Math.round(subtotal * 0.05); applied.push('tier-5'); }\n\n // coupon (percent or fixed) \u2014 stacks on top, capped so total never goes below 0\n if (coupon_code) {\n const [c] = await get<Coupon>(`${base}/v1/cms/items/coupons?filter=${enc({ code: coupon_code })}&limit=1`, cms);\n if (c) {\n discount += c.kind === 'percent' ? Math.round(subtotal * (c.value / 100)) : c.value;\n applied.push(`coupon:${c.code}`);\n }\n }\n\n const total = Math.max(0, subtotal - discount);\n return Response.json({ subtotal_cents: subtotal, discount_cents: subtotal - total, total_cents: total, applied });\n },\n};\n\nasync function get<T>(url: string, jwt: string): Promise<T[]> {\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: T }[] } };\n return (body.data?.items ?? []).map((i) => ({ id: i.item_id, ...i.data } as T));\n}\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n"
@@ -17086,7 +18108,7 @@ export default defineConfig({
17086
18108
  "readme": "# Helpdesk / Support Ticketing template\n\nA support-ticketing backend \u2014 requester-owned tickets with a hook-enforced status state machine,\nthreaded conversation messages, an acknowledgement send on every new ticket, and an hourly\nSLA-escalation cron \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**What it provisions:**\n- `tickets` \u2014 subject, `status` (state machine below), priority, `requester` (the **owner field**),\n `opened_at`, `sla_due`, body. Every queue-driving field is slot-indexed for filter/sort.\n- `ticket_messages` \u2014 the conversation thread: `ticket` relation, author, body, `sent_at`.\n- Features: `cms` + `auth` (email/password end-users) + `notifications` (mock provider) + `functions`.\n- Functions: `on-ticket-created` (cmsHook \u2192 acknowledgement send) and `sla-sweep` (hourly cron).\n\n**Apply it:**\n\n```bash\nvxil init --template helpdesk\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **A status state machine in a Lane-A hook** \u2014 the `beforeUpdate` validate allows only\n `open\u2192pending|resolved`, `pending\u2192open|resolved`, `resolved\u2192closed`; any other transition is a\n clean 422, atomically, in the write itself (vxil.com/docs/guide/07-validation-and-hooks).\n- **SLA automation as a cron function** \u2014 `sla-sweep` queries breached tickets with the \xA73 filter DSL\n (`status $in` + `sla_due $lt`, slot-indexed) and PATCHes `priority: 'urgent'`; the `$ne: 'urgent'`\n term makes re-runs idempotent.\n- **Requester-scoped end-user access** \u2014 `tickets.ownerField = 'requester'`: a signed-in requester\n sees and edits only their **own** tickets (vxil.com/docs/guide/04-data-with-cms); server keys see the queue.\n\n```ts\nconst { item_id } = await vx.from('tickets').create({\n subject: 'Cannot sign in on mobile', status: 'open', priority: 'normal',\n requester: 'user_demo', opened_at: new Date().toISOString(),\n sla_due: new Date(Date.now() + 8 * 3600e3).toISOString(), body: 'Steps to reproduce\u2026',\n});\n\n// the agent queue, most-overdue first (slot-indexed \u2192 typed filter/sort, index-served)\nconst { items } = await vx.from('tickets').query({\n filter: { status: { $in: ['open', 'pending'] } }, sort: 'sla_due', limit: 25,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 owner-scoping) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/08-running-your-code-functions \xB7 vxil.com/docs/guide/06-feature-catalog (notifications) \xB7 `examples/ecommerce/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
17087
18109
  "functions": {
17088
18110
  "on-ticket-created.ts": "// on-ticket-created.ts \u2014 the ACKNOWLEDGEMENT hook (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `tickets`. On a CREATE, send the\n// requester an acknowledgement through notifications. The cms-hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// ticket by id (through the edge, tenant-scoped) rather than trusting inline fields.\n// Delivery is at-least-once: the envelope idempotency_key rides the send as its\n// Idempotency-Key header, so a redelivered hook never double-sends.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CmsHookFunctionEnvelope;\ninterface TicketData { subject?: string; status?: string; requester?: string; sla_due?: string }\ninterface Item { data?: { data?: TicketData } }\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 const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (functions.md \xA73); this guard stays as belt-and-braces.\n if (env.payload?.collection !== 'tickets' || !cms || !notif || !itemId) {\n return Response.json({ skipped: true });\n }\n // acknowledge only the CREATE (the cms.item.* subscription also delivers updates)\n if (!String(env.payload?.event ?? '').endsWith('.created')) {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n\n // Re-fetch the ticket (the payload carries only the id).\n const res = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const ticket = ((await res.json()) as Item).data?.data ?? {};\n if (!ticket.requester) return Response.json({ skipped: true, reason: 'no requester' });\n\n // Acknowledge to the requester (email/inbox via the configured provider).\n const send = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n ...(env.idempotency_key ? { 'idempotency-key': env.idempotency_key } : {}),\n },\n body: JSON.stringify({\n user_id: ticket.requester,\n template: 'transactional',\n data: {\n subject: `We got your ticket: ${ticket.subject ?? itemId}`,\n paragraph:\n `Your ticket is ${ticket.status ?? 'open'} and in our queue` +\n `${ticket.sla_due ? ` (response due by ${ticket.sla_due})` : ''}. ` +\n 'Reply in the app to add details.',\n },\n }),\n });\n return Response.json({ acknowledged: itemId, delivery: send.status });\n },\n};\n",
17089
- "sla-sweep.ts": "// sla-sweep.ts \u2014 SLA ESCALATION CRON (a vxil function, \xA77.3).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep tickets whose sla_due has passed and\n// that are still open/pending \u2014 the cms filter DSL (vxil.com/docs/guide/04-data-with-cms):\n// `status $in` on the s2 slot, `sla_due $lt` on the t2 slot, `priority $ne` on\n// s3 \u2014 all index-served. Each breach is escalated with a PATCH to priority\n// 'urgent'; the $ne term makes re-runs idempotent (an escalated ticket falls out\n// of the filter). The Lane-A state-machine hook still runs on every PATCH; a\n// priority-only write keeps item.status == before.status, so it always passes.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Ticket { id: string; data: { subject?: string; priority?: 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\n // breached = still open/pending, past its sla_due, not yet urgent\n const filter = enc({\n status: { $in: ['open', 'pending'] },\n sla_due: { $lt: new Date().toISOString() },\n priority: { $ne: 'urgent' },\n });\n const res = await fetch(`${base}/v1/cms/items/tickets?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ error: `query ${res.status}` }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: Ticket[] } };\n const breached = body.data?.items ?? [];\n\n let escalated = 0;\n for (const t of breached) {\n const r = await fetch(`${base}/v1/cms/items/tickets/${t.id}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { priority: 'urgent' } }),\n });\n if (r.ok) escalated++;\n }\n return Response.json({ scanned: breached.length, escalated });\n },\n};\n\n// \u2500\u2500 tiny helper (the vxil REST list envelope is { data: { items } }) \u2500\u2500\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n"
18111
+ "sla-sweep.ts": "// sla-sweep.ts \u2014 SLA ESCALATION CRON (a vxil function, \xA77.3).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep tickets whose sla_due has passed and\n// that are still open/pending \u2014 the cms filter DSL (vxil.com/docs/guide/04-data-with-cms):\n// `status $in` on the s2 slot, `sla_due $lt` on the t2 slot, `priority $ne` on\n// s3 \u2014 all index-served. Each breach is escalated with a PATCH to priority\n// 'urgent'; the $ne term makes re-runs idempotent (an escalated ticket falls out\n// of the filter). The Lane-A state-machine hook still runs on every PATCH; a\n// priority-only write keeps item.status == before.status, so it always passes.\n\n// cron-walk: drains-filter \u2014 an escalated ticket gets priority 'urgent' and leaves the `$ne` filter.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Ticket { item_id: string; data: { subject?: string; priority?: 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\n // breached = still open/pending, past its sla_due, not yet urgent\n const filter = enc({\n status: { $in: ['open', 'pending'] },\n sla_due: { $lt: new Date().toISOString() },\n priority: { $ne: 'urgent' },\n });\n const res = await fetch(`${base}/v1/cms/items/tickets?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ error: `query ${res.status}` }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: Ticket[] } };\n const breached = body.data?.items ?? [];\n\n let escalated = 0;\n for (const t of breached) {\n const r = await fetch(`${base}/v1/cms/items/tickets/${t.item_id}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { priority: 'urgent' } }),\n });\n if (r.ok) escalated++;\n }\n return Response.json({ scanned: breached.length, escalated });\n },\n};\n\n// \u2500\u2500 tiny helper (the vxil REST list envelope is { data: { items } }) \u2500\u2500\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n"
17090
18112
  }
17091
18113
  },
17092
18114
  {
@@ -17204,7 +18226,7 @@ export default defineConfig({
17204
18226
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// \"IoT Fleet\" \u2014 device fleet telemetry + alerting, declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 devices \u2192 readings / alerts (resolved by relation)\n// \u2022 functions \u2192 the typed HTTP ingest endpoint + the daily aggregate rollup\n// \u2022 webhooks \u2192 fan alert writes out to the tenant's ops endpoint (outbound\n// subscriptions over the audit_event stream \u2014 webhooks.md \xA72)\n// Cross-row writes (create the reading + heartbeat the device + raise a\n// threshold alert) are exactly why ingest is a FUNCTION, not a Lane-A hook \u2014\n// hooks are pure single-row expressions. Everything here is DATA the tenant\n// owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // Every reading needs a metric name \u2014 a pure function of the row (Lane-A validate).\n reading_metric: {\n collection: 'readings',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.metric) > 0',\n message: 'a reading needs a metric name',\n },\n },\n // The DECLARATIVE rollup alternative to functions/daily-rollup.ts: a\n // `readModels` config entry (cms.md \xA712.4) can materialize the same\n // per-device aggregate on cron into a dedicated rollup collection. This\n // blueprint keeps the imperative function so you can read the endpoint\n // it rides on (POST /v1/cms/items/readings/aggregate, \xA712.2).\n },\n\n // Outbound subscriptions are RUNTIME rows, not config keys:\n // POST /v1/webhooks/subscriptions { target_url, event_prefixes: ['cms.item.'] }\n // fans every cms write (including new alert rows) to your ops endpoint as a\n // signed jobs callback \u2014 verify X-Vxil-Jobs-Signature, filter for alerts.\n webhooks: {},\n\n functions: { enabled: true }, // the \xA77.3 crossing: opt-in, egress-guarded\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n devices: {\n singular: 'device',\n fields: {\n serial: { type: 'string', required: true, indexSlot: 's1', unique: true },\n model: { type: 'string', indexSlot: 's2' },\n site: { type: 'string', indexSlot: 's3' },\n status: { type: 'string', indexSlot: 's4', validation: { enum: ['active', 'maintenance', 'retired'] } },\n last_seen: { type: 'datetime', indexSlot: 't1' }, // heartbeat, PATCHed by ingest-reading\n battery_pct: { type: 'int', indexSlot: 'n1', validation: { min: 0, max: 100 } },\n },\n },\n readings: {\n singular: 'reading',\n fields: {\n device: { type: 'relation', relationTo: 'devices', required: true, indexSlot: 's1' },\n metric: { type: 'string', required: true, indexSlot: 's2' }, // e.g. battery_pct, temp_c\n value: { type: 'float', required: true, indexSlot: 'n1' }, // n-slot \u2192 min/max/avg aggregates\n recorded_at: { type: 'datetime', indexSlot: 't1' }, // t-slot \u2192 the 24h aggregate window\n },\n },\n alerts: {\n singular: 'alert',\n fields: {\n device: { type: 'relation', relationTo: 'devices', required: true, indexSlot: 's1' },\n kind: { type: 'string', required: true, indexSlot: 's2' }, // low_battery | daily_rollup | \u2026\n severity: { type: 'string', indexSlot: 's3', validation: { enum: ['info', 'warning', 'critical'] } },\n raised_at: { type: 'datetime', indexSlot: 't1' },\n note: { type: 'text' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions (functions.md) \u2500\u2500\n functions: {\n // The typed device endpoint: POST /v1/fn/ingest-reading { device_id, metric, value }\n // \u2192 create the reading + heartbeat the device + threshold-alert, in one call.\n // The alert write is deduped with a `lock` + `guard` WRITE BODY (cms.md \xA710):\n // at most ONE live low_battery alert per device \u2014 never a config key.\n 'ingest-reading': {\n entry: './functions/ingest-reading.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // The daily rollup: ONE bounded \xA712.2 aggregate call (per-device min/max/avg\n // over the last 24h of battery_pct readings) \u2192 one info summary row per device\n // per UTC day (lock+guard-deduped \u2014 cron delivery is at-least-once).\n 'daily-rollup': {\n entry: './functions/daily-rollup.ts',\n trigger: { kind: 'cron', schedule: '0 6 * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'devices',\n items: [\n { serial: 'SN-0001', model: 'env-sensor-2', site: 'plant-a', status: 'active', battery_pct: 87 },\n { serial: 'SN-0002', model: 'env-sensor-2', site: 'plant-b', status: 'active', battery_pct: 42 },\n ],\n },\n ],\n },\n});\n",
17205
18227
  "readme": '# IoT Fleet / Telemetry template\n\nA device-fleet backend \u2014 devices, telemetry readings, and alerts \u2014 declared end-to-end in one typed\n`vxil.config.ts`, with the domain logic that isn\'t config (ingest, thresholds, rollups) as two tenant\nfunctions.\n\n**What it provisions:**\n- `devices` \u2014 unique serial, model, site, lifecycle status, `last_seen` heartbeat, `battery_pct`.\n- `readings` \u2014 `device` relation, metric name, float `value` (n-slot \u2192 aggregates), `recorded_at` (t-slot \u2192 windows).\n- `alerts` \u2014 `device` relation, kind, severity, `raised_at`, free-text note.\n- Features: `cms` (+ a Lane-A validate hook), `webhooks` (outbound fan-out), `functions`\n (`ingest-reading` on http, `daily-rollup` on a daily cron).\n\n**Apply it:**\n\n```bash\nvxil init --template iot-fleet\nvxil quickstart # or `vxil link` an existing tenant\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **HTTP ingest functions as typed device endpoints** \u2014 `functions/ingest-reading.ts` turns one\n `POST /v1/fn/ingest-reading` (the key needs the `functions:invoke` scope) into three cross-row\n writes: create the reading, PATCH the device heartbeat, raise a threshold alert. Cross-row work\n is exactly why this is a function, not a hook.\n- **Guard-deduped threshold alerting** \u2014 the alert POST carries `lock` + `guard` in the WRITE BODY\n (vxil.com/docs/api: the item write routes): at most one live `low_battery` alert per device, race-safe. Deleting\n the alert row resolves it and frees the guard.\n- **Bounded aggregates for rollups** \u2014 `functions/daily-rollup.ts` calls\n `POST /v1/cms/items/readings/aggregate` (\xA712.2: \u2264500 groups over a \u226450k scan) for per-device\n daily min/max/avg. Cron delivery is at-least-once, so each summary write is itself lock+guard-deduped\n to one per (device, day); the declarative sibling is a `readModels` config entry (\xA712.4).\n- **Webhook fan-out of alerts** \u2014 `POST /v1/webhooks/subscriptions` with\n `{ "target_url": "https://ops.example.com/hook", "event_prefixes": ["cms.item."] }` delivers every\n cms write (including new alerts) as a signed jobs callback \u2014 verify `X-Vxil-Jobs-Signature`.\n\n```bash\ncurl -X POST https://api.vxil.com/v1/fn/ingest-reading \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"device_id":"itm_...","metric":"battery_pct","value":12}\'\n```\n\nTyped SDK query for the ops dashboard: `vx.from(\'devices\').query({ filter: { battery_pct: { $lt: 20 } }, sort: \'-last_seen\' })`.\n\n**Go deeper:** vxil.com/docs/api (lock/guard, aggregates, read-models),\nvxil.com/docs/guide/08-running-your-code-functions, vxil.com/docs/guide/06-feature-catalog (webhooks), and `examples/ecommerce/` for a larger\nfunction saga. The config is yours after `init` \u2014 nothing is locked.\n',
17206
18228
  "functions": {
17207
- "daily-rollup.ts": "// daily-rollup.ts \u2014 BOUNDED AGGREGATES FOR ROLLUPS (a vxil function, cron trigger).\n//\n// Runs daily (cron 0 6 * * *). ONE call to the real group-by aggregate endpoint \u2014\n// POST /v1/cms/items/readings/aggregate (cms.md \xA712.2) \u2014 computes per-device\n// min/max/avg/count of battery_pct over the last 24h (grouped by the slot-bound\n// `device` relation; hard-bounded server-side: \u2264500 groups over a \u226450k-row scan,\n// else a clean 422 window_too_large), then writes one `daily_rollup` info row into\n// `alerts` per device. Cron delivery is at-least-once and cms does NOT dedupe on\n// the Idempotency-Key header, so exactly-once-per-day is enforced with the \xA710\n// lock+guard WRITE BODY: at most ONE daily_rollup per (device, UTC day) \u2014 a\n// redelivered run cleanly 409s. The DECLARATIVE alternative is a `readModels`\n// config entry materializing on cron (cms.md \xA712.4).\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Group { key: { device?: string }; count: number; min?: number; max?: number; avg?: number }\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 const now = new Date().toISOString();\n\n // 1. the bounded aggregate: min/max/avg need `value` on an n-slot (n1);\n // the 24h window rides the t-slot (t1) on `recorded_at`.\n const agg = await fetch(`${base}/v1/cms/items/readings/aggregate`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n aggregates: [\n { fn: 'min', field: 'value', as: 'min' },\n { fn: 'max', field: 'value', as: 'max' },\n { fn: 'avg', field: 'value', as: 'avg' },\n { fn: 'count' },\n ],\n groupBy: ['device'],\n filter: { metric: 'battery_pct', $status: 'published' },\n window: { field: 'recorded_at', sinceDays: 1 },\n limit: 500,\n }),\n });\n if (!agg.ok) return json({ error: 'aggregate_failed', status: agg.status }, 502);\n const groups = ((await agg.json()) as { data?: { groups?: Group[] } }).data?.groups ?? [];\n\n // 2. one published summary row per (device, UTC day) \u2014 the guard counts today's\n // live daily_rollup rows for the device under the lock, so a redelivered\n // cron run 409s instead of duplicating (cms.md \xA710).\n const day = now.slice(0, 10);\n let written = 0;\n for (const g of groups) {\n if (!g.key.device) continue;\n const r = await fetch(`${base}/v1/cms/items/alerts`, {\n method: 'POST',\n headers: env.idempotency_key ? { ...H, 'idempotency-key': `${env.idempotency_key}:${g.key.device}` } : H,\n body: JSON.stringify({\n status: 'published',\n lock: `rollup:${g.key.device}`,\n guard: {\n filter: { device: g.key.device, kind: 'daily_rollup', raised_at: { $gte: `${day}T00:00:00.000Z` } },\n max: 1,\n },\n data: {\n device: g.key.device, kind: 'daily_rollup', severity: 'info', raised_at: now,\n note: `battery_pct last 24h \u2014 min ${g.min} \xB7 max ${g.max} \xB7 avg ${round1(g.avg)} over ${g.count} readings`,\n },\n }),\n });\n if (r.ok) written += 1;\n }\n return json({ devices: groups.length, written }, 200);\n },\n};\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\nconst round1 = (n?: number) => (typeof n === 'number' ? Math.round(n * 10) / 10 : n);\n",
18229
+ "daily-rollup.ts": "// daily-rollup.ts \u2014 BOUNDED AGGREGATES FOR ROLLUPS (a vxil function, cron trigger).\n//\n// Runs daily (cron 0 6 * * *). ONE call to the real group-by aggregate endpoint \u2014\n// POST /v1/cms/items/readings/aggregate (cms.md \xA712.2) \u2014 computes per-device\n// min/max/avg/count of battery_pct over the last 24h (grouped by the slot-bound\n// `device` relation; hard-bounded server-side: \u2264500 groups over a \u226450k-row scan,\n// else a clean 422 window_too_large), then writes one `daily_rollup` info row into\n// `alerts` per device. Cron delivery is at-least-once and cms does NOT dedupe on\n// the Idempotency-Key header, so exactly-once-per-day is enforced with the \xA710\n// lock+guard WRITE BODY: at most ONE daily_rollup per (device, UTC day) \u2014 a\n// redelivered run cleanly 409s. The DECLARATIVE alternative is a `readModels`\n// config entry materializing on cron (cms.md \xA712.4).\n\n// cron-walk: single-read \u2014 one bounded server-side aggregate is the whole job; nothing to page.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Group { key: { device?: string }; count: number; min?: number; max?: number; avg?: number }\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 const now = new Date().toISOString();\n\n // 1. the bounded aggregate: min/max/avg need `value` on an n-slot (n1);\n // the 24h window rides the t-slot (t1) on `recorded_at`.\n const agg = await fetch(`${base}/v1/cms/items/readings/aggregate`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n aggregates: [\n { fn: 'min', field: 'value', as: 'min' },\n { fn: 'max', field: 'value', as: 'max' },\n { fn: 'avg', field: 'value', as: 'avg' },\n { fn: 'count' },\n ],\n groupBy: ['device'],\n filter: { metric: 'battery_pct', $status: 'published' },\n window: { field: 'recorded_at', sinceDays: 1 },\n limit: 500,\n }),\n });\n if (!agg.ok) return json({ error: 'aggregate_failed', status: agg.status }, 502);\n const groups = ((await agg.json()) as { data?: { groups?: Group[] } }).data?.groups ?? [];\n\n // 2. one published summary row per (device, UTC day) \u2014 the guard counts today's\n // live daily_rollup rows for the device under the lock, so a redelivered\n // cron run 409s instead of duplicating (cms.md \xA710).\n const day = now.slice(0, 10);\n let written = 0;\n for (const g of groups) {\n if (!g.key.device) continue;\n const r = await fetch(`${base}/v1/cms/items/alerts`, {\n method: 'POST',\n headers: env.idempotency_key ? { ...H, 'idempotency-key': `${env.idempotency_key}:${g.key.device}` } : H,\n body: JSON.stringify({\n status: 'published',\n lock: `rollup:${g.key.device}`,\n guard: {\n filter: { device: g.key.device, kind: 'daily_rollup', raised_at: { $gte: `${day}T00:00:00.000Z` } },\n max: 1,\n },\n data: {\n device: g.key.device, kind: 'daily_rollup', severity: 'info', raised_at: now,\n note: `battery_pct last 24h \u2014 min ${g.min} \xB7 max ${g.max} \xB7 avg ${round1(g.avg)} over ${g.count} readings`,\n },\n }),\n });\n if (r.ok) written += 1;\n }\n return json({ devices: groups.length, written }, 200);\n },\n};\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\nconst round1 = (n?: number) => (typeof n === 'number' ? Math.round(n * 10) / 10 : n);\n",
17208
18230
  "ingest-reading.ts": "// ingest-reading.ts \u2014 THE TYPED DEVICE ENDPOINT (a vxil function, http trigger).\n//\n// POST /v1/fn/ingest-reading with { device_id, metric, value } \u2014 one call from a\n// device (or gateway) does the three cross-row writes no Lane-A hook may do:\n// 1. create the `readings` row (published, so the daily aggregate window sees it;\n// the envelope idempotency key is passed through as the write's Idempotency-Key\n// header \u2014 the platform convention, functions.md \xA76e)\n// 2. PATCH the device: last_seen = now (+ battery_pct when the metric carries it)\n// 3. THRESHOLD ALERTING: battery below 15 \u2192 create a low_battery alert, deduped\n// by a lock+guard WRITE BODY (at most ONE live alert per device \u2014 cms.md \xA710).\n// The guard is the dedupe that matters \u2014 a retried reading create is telemetry noise.\n// Resolve an alert by deleting its row; that frees the guard for the next one.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload` (functions.md \xA72)\ntype Env = HttpFunctionEnvelope<{ device_id?: string; metric?: string; value?: number }>;\ninterface Created { data?: { item_id?: string } } // cms responses are { data: {\u2026}, meta }\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 { device_id, metric } = env.payload ?? {};\n const value = Number(env.payload?.value);\n if (!device_id || !metric || !Number.isFinite(value)) {\n return json({ error: 'device_id, metric and numeric value required' }, 400);\n }\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = new Date().toISOString();\n\n // 0. the device must exist (404 from cms = unknown or deleted device)\n const dev = await fetch(`${base}/v1/cms/items/devices/${device_id}`, { headers: H });\n if (!dev.ok) return json({ error: 'unknown_device' }, 404);\n\n // 1. create the reading \u2014 published so the daily aggregate window sees it\n const rd = await fetch(`${base}/v1/cms/items/readings`, {\n method: 'POST',\n headers: env.idempotency_key ? { ...H, 'idempotency-key': env.idempotency_key } : H,\n body: JSON.stringify({\n status: 'published',\n data: { device: device_id, metric, value, recorded_at: now },\n }),\n });\n if (!rd.ok) return json({ error: 'reading_create_failed', status: rd.status }, 502);\n const reading = ((await rd.json()) as Created).data ?? {};\n\n // 2. heartbeat the device (merge-patch; battery only when this metric carries it)\n const patch: Record<string, unknown> = { last_seen: now };\n if (metric === 'battery_pct') patch.battery_pct = Math.max(0, Math.min(100, Math.round(value)));\n await fetch(`${base}/v1/cms/items/devices/${device_id}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data: patch }),\n });\n\n // 3. threshold alert \u2014 `guard` counts live rows under the ONE advisory `lock`,\n // so N racing low-battery ingests raise exactly one alert (the rest 409).\n let alert_id: string | undefined;\n if (metric === 'battery_pct' && value < 15) {\n const al = await fetch(`${base}/v1/cms/items/alerts`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n status: 'published',\n lock: `dev:${device_id}:low_battery`,\n guard: { filter: { device: device_id, kind: 'low_battery' }, max: 1 },\n data: { device: device_id, kind: 'low_battery', severity: 'warning', raised_at: now, note: `battery at ${value}%` },\n }),\n });\n if (al.ok) alert_id = ((await al.json()) as Created).data?.item_id;\n // 409 guard_failed \u2192 an open low_battery alert already exists; nothing to do.\n }\n\n return json({ reading_id: reading.item_id, alert_id }, 201);\n },\n};\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n"
17209
18231
  }
17210
18232
  },
@@ -17212,9 +18234,12 @@ export default defineConfig({
17212
18234
  "id": "push-notifications",
17213
18235
  "title": "Push notifications",
17214
18236
  "vertical": "mobile",
17215
- "summary": "The part of push that actually breaks: the device registry. A cms collection with a unique token and an owner field, a function that re-owns a token instead of duplicating it when the handset changes hands, a per-device localized sender, and a nightly receipts cron that deletes the dead tokens your push service has already given up on.",
18237
+ "summary": "The supported Expo path on vxil. The part of push that actually breaks is the device registry: a cms collection with a unique token and an owner field, a function that re-owns a token instead of duplicating it when the handset changes hands (from your backend at once, from the app through a claim and a server-mode hook), clean-up when a user is erased or deleted, a per-device localized sender with an hourly reminder ladder that honours opt-out and quiet hours, and a receipts cron that walks the whole registry and deletes the dead tokens your push service has already given up on.",
17216
18238
  "collections": [
17217
- "push_tokens"
18239
+ "push_tokens",
18240
+ "push_token_claims",
18241
+ "push_prefs",
18242
+ "push_state"
17218
18243
  ],
17219
18244
  "features": [
17220
18245
  "cms",
@@ -17222,174 +18247,12 @@ export default defineConfig({
17222
18247
  ],
17223
18248
  "hasFunctions": true,
17224
18249
  "byoKeys": [],
17225
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// \"Push notifications\" \u2014 DEVICE TOKENS DONE PROPERLY.\n//\n// Sending a push is the easy part: one HTTPS POST to your push service. What\n// actually breaks in production is the REGISTRY \u2014 the mapping from a user to\n// the devices they are currently holding. Tokens rotate, phones get sold, users\n// sign out and a colleague signs in on the same handset, and a token that once\n// belonged to Alice starts delivering Alice's notifications to Bob. That is a\n// privacy incident, not a bug.\n//\n// So this blueprint is a registry with three invariants and a small amount of\n// sending bolted on:\n// 1. ONE TOKEN, ONE USER \u2014 `token` is `unique`, enforced by the platform.\n// 2. RE-OWN ON CHANGE \u2014 a token that reappears under a different user is\n// REASSIGNED, never duplicated.\n// 3. PRUNE STALE \u2014 the push service tells you when a token is dead; a cron\n// collects those receipts and deletes the rows, so your registry shrinks.\n//\n// \u2022 cms \u2192 `push_tokens`: owner-scoped, `token` unique\n// \u2022 functions \u2192 `register-token` (the registry), `send-push` (fan-out to the\n// push service through the egress allowlist), `prune-receipts`\n// (the daily garbage collector)\n//\n// Written against Expo's push service because it fronts BOTH APNs and FCM with\n// one token format. Swapping in raw FCM or APNs is a change to two constants\n// and the message body \u2014 see README.md.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n // Fail-safe: a verified end-user may only touch collections that declare\n // an ownerField. `push_tokens` does, so a signed-in device can read and\n // refresh its OWN rows and structurally cannot enumerate anyone else's.\n strictEndUserScope: true,\n hooks: {\n // A row with no token is a row that can never receive anything.\n token_present: {\n collection: 'push_tokens',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.token) > 0',\n message: 'a push token is required',\n },\n },\n },\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n push_tokens: {\n singular: 'push_token',\n // Owner-scoped: an end-user session sees only its own devices.\n ownerField: 'end_user',\n fields: {\n // \u2605 INVARIANT 1. `unique` is the TOP-LEVEL field attribute (there is no\n // `validation.unique`). The platform backs it with a claims table, so a\n // second registration of the same token is a 409 `unique_violation` \u2014\n // atomically, under concurrency, without a read-then-write race.\n // NOTE: a unique value is capped at 256 characters. Expo tokens\n // (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside that;\n // if you switch to raw FCM registration tokens, store a hash here and\n // keep the full token in an unindexed `text` field.\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' }, // the owner\n platform: { type: 'string', indexSlot: 's3', validation: { enum: ['ios', 'android', 'web'] } },\n // The device's language, captured at registration \u2014 this is what makes\n // a localized push possible without a second lookup per device.\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'fr', \u2026\n // The receipt id returned by the LAST send. `prune-receipts` trades it\n // for a verdict and deletes the row when the service says the device\n // is gone.\n receipt_id: { type: 'string' },\n // Consecutive delivery failures \u2014 a soft signal for your own dashboards\n // (the authoritative kill signal is the receipt, not this counter).\n failures: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_seen: { type: 'datetime', indexSlot: 't1' }, // last registration/refresh\n checked_at: { type: 'datetime', indexSlot: 't2' }, // last receipt check\n last_error: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // THE REGISTRY. POST /v1/fn/register-token { token, user_id, platform, locale }.\n // Call it from your app's authenticated backend (server mode) \u2014 that is where\n // the re-own path lives, because reassigning a token away from another user\n // needs tenant-wide read, which an end-user-mode invocation deliberately does\n // not have. See README.md \xA7\"Why register-token is a server call\".\n 'register-token': {\n entry: './functions/register-token.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // pure vxil-internal\n },\n\n // THE SENDER. POST /v1/fn/send-push { user_ids, messages, data? } \u2014 resolves\n // each user's live devices, picks the message for each device's locale, and\n // posts to the push service in batches.\n 'send-push': {\n entry: './functions/send-push.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write'],\n // Deny-by-default egress. `exp.host` is Expo's push host; `api.expo.dev`\n // is its newer alias. For raw FCM use 'fcm.googleapis.com'; for APNs,\n // 'api.push.apple.com' (and see README.md on the token-auth swap).\n egressAllow: ['exp.host', 'api.expo.dev'],\n // NO `secrets` here on purpose. Expo's push API needs no credential in its\n // default configuration, and secret resolution is FAIL-CLOSED: a declared\n // `secret:<name>` ref with no stored value makes every invocation\n // `409 secret_missing` before your code runs. Declare a secret only once\n // you will actually set it \u2014 see README.md \xA7\"If your push service needs a key\".\n },\n\n // \u2605 INVARIANT 3. THE GARBAGE COLLECTOR. A push service accepts a send and\n // only later tells you the device was gone \u2014 that verdict arrives as a\n // RECEIPT, not as the send response. This cron trades yesterday's receipt\n // ids for verdicts and deletes the rows the service declared dead.\n 'prune-receipts': {\n entry: './functions/prune-receipts.ts',\n trigger: { kind: 'cron', schedule: '30 3 * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: ['exp.host', 'api.expo.dev'],\n },\n },\n});\n",
17226
- "readme": "# Push notifications (mobile)\n\nSending a push is the easy part: one HTTPS POST to your push service. What breaks in production is the\n**registry** \u2014 the mapping from a user to the devices they are currently holding.\n\nTokens rotate. Phones get sold. A user signs out and a colleague signs in on the same handset. Unless\nsomething reassigns that token, the previous owner's notifications keep arriving on someone else's lock\nscreen \u2014 a privacy incident, not a bug. And nobody collects delivery **receipts**, so the registry only\never grows: dead tokens are re-sent to forever and delivery rates look worse than they are.\n\nThis blueprint is that registry, with three invariants, plus a small amount of sending.\n\n```bash\nvxil init --template push-notifications\nvxil quickstart # or `vxil link <slug>` for an existing backend\nvxil push # the push_tokens collection + three functions\n```\n\n## The three invariants\n\n| # | Invariant | Where it is enforced |\n|---|---|---|\n| **1** | **One token, one user** | `token: { unique: true }` \u2014 the *top-level* field attribute, backed by a claims table. A second registration of the same token is a `409 unique_violation`, atomically, with no read-then-write race. |\n| **2** | **Re-own on change** | `functions/register-token.ts` \u2014 on that 409 it looks the row up and **PATCHes the owner** with `If-Match`. Reassign, never duplicate. |\n| **3** | **Prune stale** | `functions/prune-receipts.ts` \u2014 a nightly cron trades yesterday's receipt ids for verdicts and **deletes** every row the service reports as `DeviceNotRegistered`. |\n\nThere is no `validation: { unique: true }` \u2014 that key does not exist and would be silently ignored.\nUniqueness is the top-level `unique` attribute, on scalar field types only.\n\n## The registry\n\n```ts\npush_tokens: {\n ownerField: 'end_user', // a signed-in device sees only its own rows\n fields: {\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' },\n platform: { type: 'string', indexSlot: 's3' }, // ios | android | web\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'pt-BR', \u2026\n receipt_id: { type: 'string' }, // the last send's pending verdict\n failures: { type: 'int', indexSlot: 'n1' },\n last_seen: { type: 'datetime', indexSlot: 't1' },\n checked_at: { type: 'datetime', indexSlot: 't2' },\n last_error: { type: 'text' },\n },\n}\n```\n\n`strictEndUserScope: true` is on, so a verified end-user session may only touch collections that declare\nan `ownerField`. `push_tokens` does \u2014 a device can refresh **its own** rows and structurally cannot\nenumerate anyone else's.\n\n> **The 256-character cap.** A unique value is capped at 256 characters. Expo tokens\n> (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside it. If you switch to raw **FCM\n> registration tokens**, store a hash in `token` and keep the full value in an unindexed `text` field \u2014\n> otherwise long tokens are rejected at write time.\n\n## Why `register-token` is a server call\n\n`POST /v1/fn/register-token { token, user_id, platform?, locale? }`\n\nInvariant 2 needs to reassign a token **away from another user**, which needs a tenant-wide read. A\nfunction invoked with a verified end-user session gets **owner-scoped** callback tokens \u2014 the platform\nstamps the principal onto every scoped token the function mints downstream \u2014 so in end-user mode the\nother user's row is simply invisible. That is the fail-safe working, not a gap.\n\nSo: call `register-token` from your app's authenticated backend. If you do invoke it in end-user mode,\ntwo things happen, both deliberate:\n\n- the **verified** `end_user.id` overrides any `user_id` in the body \u2014 a client can never register a\n token against someone else's account;\n- the cross-owner hand-off returns `409 token_owned_by_another_user` instead of silently doing nothing.\n\nResponses: `201 {action:'created'}`, `200 {action:'reowned', previous_owner}`, `200 {action:'refreshed'}`.\n\n## Sending \u2014 localized per device, not per user\n\n`POST /v1/fn/send-push`\n\n```json\n{\n \"user_ids\": [\"usr_a\", \"usr_b\"],\n \"messages\": {\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\" }\n}\n```\n\nOne person can carry an English phone and an Arabic tablet. Because `locale` lives on the **token row**,\neach device gets the right copy with no extra lookup \u2014 exact match (`pt-BR`), then the base language\n(`pt`), then `default`.\n\nThe response distinguishes the two failure kinds, because they call for opposite actions:\n\n- an **immediate** `DeviceNotRegistered` \u2192 the token is dead now; the row is deleted;\n- anything else (rate limits, a message too big, a transient fault) \u2192 counted in `failures`, device kept.\n Deleting a device because one message was malformed is how a registry loses real users.\n- an **accepted** message returns a *receipt id*, not a delivery. That id is parked on the row, and the\n real verdict is collected by the cron below.\n\n## The receipts cron \u2014 the loop almost nobody closes\n\n`prune-receipts` runs at `30 3 * * *`: it pages `push_tokens`, collects the rows carrying a `receipt_id`,\nasks the push service for those receipts in batches, and acts \u2014 `ok` clears the id, `DeviceNotRegistered`\ndeletes the row, anything else counts a failure and keeps the device. A receipt that is not ready yet is\nsimply absent from the response, which is not an error; it is checked again tomorrow.\n\nEverything is bounded: at most 2 000 rows and a handful of outbound calls per tick. A larger registry is\npruned over several nights, which is fine \u2014 a dead token costs one wasted message a day, not correctness.\n\n> **A paging detail worth stealing.** The cron pages with `?cursor=` and **no** `?sort=`. cms cursor\n> pagination works with the default sort only; pairing `?cursor=` with a `?sort=` is a clean `422`, and\n> `next_cursor` is not emitted for a custom sort at all. When you need to walk *everything*, take the\n> default order.\n\n## Example: post-purchase push on `payments.entitlement.changed`\n\nThe payments feature writes `payments.entitlement.changed` to your audit stream on every entitlement\ntransition, carrying `end_user_id`, the previous and new tier, `status`, `until`, `reason` and\n`environment`. Turning that into \"your Pro plan is live, in the language of the device\" is a wiring\nexercise, not new code \u2014 there are two shapes, both using the same `send-push` call:\n\n**(a) Subscribe and call in.** Outbound subscriptions are runtime rows, not config:\n\n```bash\ncurl -X POST https://api.vxil.com/v1/webhooks/subscriptions \\\n -H \"authorization: Bearer $VXIL_API_KEY\" -H 'content-type: application/json' \\\n -d '{\"target_url\":\"https://your-app.example.com/hooks/vxil\",\"event_prefixes\":[\"payments.entitlement.\"]}'\n```\n\nYour endpoint verifies the signed delivery, then calls `send-push` with a key holding\n`functions:invoke`:\n\n```ts\n// inside your webhook receiver \u2014 event.payload.end_user_id / .tier are the fields above\nawait fetch('https://api.vxil.com/v1/fn/send-push', {\n method: 'POST',\n headers: { authorization: `Bearer ${process.env.VXIL_API_KEY}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_ids: [event.payload.end_user_id],\n messages: {\n default: { title: 'You\\'re in', body: `${event.payload.tier} is active on this device.` },\n ar: { title: '\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644', body: `\u062A\u0645 \u062A\u0641\u0639\u064A\u0644 ${event.payload.tier} \u0639\u0644\u0649 \u0647\u0630\u0627 \u0627\u0644\u062C\u0647\u0627\u0632.` },\n },\n data: { screen: 'billing', tier: event.payload.tier },\n }),\n});\n```\n\n**(b) Drain the audit stream on a cron.** If you would rather keep everything inside your backend, the\nwatermark-plus-allow-list pattern in [`templates/alerts-to-slack`](../alerts-to-slack) reads the same\nevents with `GET /v1/audit/export?after_id=\u2026` and needs no public endpoint at all. Same events, no\ninbound surface.\n\nWhichever you pick, send on the **`from_tier \u2192 tier` crossing**, not on every event: entitlement snapshots\nare refolded on redelivery, and \"Pro is active\" three times is how an app gets its notifications muted.\n\n## Swapping the push service\n\nThe blueprint targets Expo because it fronts **both** APNs and FCM with one token format. To swap:\n\n| Service | `PUSH_URL` | `egressAllow` | Also change |\n|---|---|---|---|\n| Expo (default) | `https://exp.host/--/api/v2/push/send` | `exp.host`, `api.expo.dev` | \u2014 |\n| FCM (HTTP v1) | the HTTP v1 send method under `https://fcm.googleapis.com/v1/projects/<id>/` \u2014 the exact URL is in `functions/send-push.ts` | `fcm.googleapis.com` | one message per request; an OAuth bearer; the `message.notification` body shape; hash long tokens (see the 256-char cap above) |\n| APNs | `https://api.push.apple.com/3/device/<token>` | `api.push.apple.com` | one request per device; a JWT `authorization`; an `apns-topic` header; the `aps` payload shape |\n\n### If your push service needs a key\n\nAdd the ref to the function and set the value:\n\n```ts\n'send-push': { /* \u2026 */ secrets: ['secret:push_api_key'] }\n```\n```bash\nprintf '%s' \"$KEY\" | vxil secrets set functions/push_api_key\n```\n\nIt resolves per invocation as `envelope.secrets.push_api_key`, so **rotating it needs no redeploy**. One\nwarning: resolution is **fail-closed**. A declared `secret:<name>` ref with no stored value makes every\ninvocation `409 secret_missing` before your code runs \u2014 which is why this blueprint declares no secrets\nat all by default. Declare one only when you will actually set it.\n\n## What to learn from this\n\n- **The hard part of push is ownership, not delivery.** A `unique` field plus one re-own path is the\n whole difference between a registry that stays correct and one that leaks notifications between users.\n- **A verified principal beats a body field, always.** The platform hands the function `end_user.id`; the\n request body is a suggestion.\n- **Delete on the verdict, not on the error.** `DeviceNotRegistered` is the only signal that justifies\n removing a device. Everything else is about the message.\n\n**Pairs with:** [`templates/alerts-to-slack`](../alerts-to-slack) for the audit-stream drain used in\nexample (b).\n",
18250
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// \"Push notifications\" \u2014 DEVICE TOKENS DONE PROPERLY.\n//\n// Sending a push is the easy part: one HTTPS POST to your push service. What\n// actually breaks in production is the REGISTRY \u2014 the mapping from a user to\n// the devices they are currently holding. Tokens rotate, phones get sold, users\n// sign out and a colleague signs in on the same handset, and a token that once\n// belonged to Alice starts delivering Alice's notifications to Bob. That is a\n// privacy incident, not a bug.\n//\n// So this blueprint is a registry with three invariants and a small amount of\n// sending bolted on \u2014 and it is THE supported Expo path on vxil (guide 17):\n// 1. ONE TOKEN, ONE USER \u2014 `token` is `unique`, enforced by the platform.\n// 2. RE-OWN ON CHANGE \u2014 a token that reappears under a different user is\n// REASSIGNED, never duplicated. From your backend (server mode) that is\n// immediate; from the app itself (end-user mode, which cannot see another\n// user's row) it is ASYNC: a claim row + a server-mode cmsHook.\n// 3. PRUNE STALE \u2014 the push service tells you when a token is dead; a cron\n// collects those receipts and deletes the rows, so your registry shrinks.\n// It walks the WHOLE registry over successive ticks (a persisted cursor).\n// Plus the two things every consumer app ends up writing:\n// \u2022 ERASURE \u2014 a user erased under GDPR (auth.user.erased / user.erased) or\n// deleted (user.deleted) loses every device row, claim and preferences\n// row \u2014 never on session.revoked, which fires on every refresh.\n// \u2022 A REMINDER LADDER \u2014 an hourly cron nudging inactive users (1 d, 3 d, 7 d),\n// honouring each user's opt-out and quiet hours in THEIR time zone.\n//\n// \u2022 cms \u2192 `push_tokens` (owner-scoped, `token` unique), `push_token_claims`\n// (an app's async re-own request), `push_prefs` (reminders,\n// time zone, quiet hours, last activity), `push_state` (cursors)\n// \u2022 functions \u2192 `register-token` (the registry: http + the claim cmsHook + the\n// erasure webhooks), `send-push` (http send + the hourly ladder\n// cron), `prune-receipts` (the receipts cron)\n//\n// Three functions and two crons (the fastest hourly) on purpose: that is inside\n// the Free plan's function limits for staging and development projects (guide\n// 12 has the per-plan table), so the whole kit runs on a free project while\n// you build the app. Every tick is bounded and resumable, so a tick the plan\n// sheds is harmless: the next one picks up where the last stopped.\n//\n// Written against Expo's push service because it fronts BOTH APNs and FCM with\n// one token format. Swapping in raw FCM or APNs is a change to two constants\n// and the message body \u2014 see README.md.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n // Fail-safe: a verified end-user may only touch collections that declare\n // an ownerField. `push_tokens` does, so a signed-in device can read and\n // refresh its OWN rows and structurally cannot enumerate anyone else's.\n strictEndUserScope: true,\n hooks: {\n // A row with no token is a row that can never receive anything.\n token_present: {\n collection: 'push_tokens',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.token) > 0',\n message: 'a push token is required',\n },\n // The ladder walk filters on `ladder_step < 3` (a slot compare, which\n // never matches an absent value), so every prefs row carries one: a\n // write that leaves it out gets 0.\n ladder_step_default: {\n collection: 'push_prefs',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'ladder_step',\n expr: 'coalesce(item.ladder_step, 0)',\n },\n },\n },\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n push_tokens: {\n singular: 'push_token',\n // Owner-scoped: an end-user session sees only its own devices.\n ownerField: 'end_user',\n fields: {\n // \u2605 INVARIANT 1. `unique` is the TOP-LEVEL field attribute (there is no\n // `validation.unique`). The platform backs it with a claims table, so a\n // second registration of the same token is a 409 `unique_violation` \u2014\n // atomically, under concurrency, without a read-then-write race.\n // NOTE: a unique value is capped at 256 characters. Expo tokens\n // (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside that;\n // if you switch to raw FCM registration tokens, store a hash here and\n // keep the full token in an unindexed `text` field.\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' }, // the owner\n platform: { type: 'string', indexSlot: 's3', validation: { enum: ['ios', 'android', 'web'] } },\n // The device's language, captured at registration \u2014 this is what makes\n // a localized push possible without a second lookup per device.\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'fr', \u2026\n // The receipt id returned by the LAST send. `prune-receipts` trades it\n // for a verdict and deletes the row when the service says the device\n // is gone.\n receipt_id: { type: 'string' },\n // When that receipt id was parked. The push service keeps a receipt\n // for a limited time; past RECEIPT_MAX_AGE the id is cleared as\n // unknowable instead of being asked about forever.\n receipt_at: { type: 'datetime' },\n // Consecutive delivery failures \u2014 a soft signal for your own dashboards\n // (the authoritative kill signal is the receipt, not this counter).\n failures: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_seen: { type: 'datetime', indexSlot: 't1' }, // last registration/refresh\n checked_at: { type: 'datetime', indexSlot: 't2' }, // last receipt check\n last_error: { type: 'text' },\n },\n },\n\n // \u2605 INVARIANT 2, the app's half. An end-user session cannot see \u2014 let\n // alone re-own \u2014 a row that belongs to someone else, so when the app\n // registers a token another user holds, `register-token` files a CLAIM\n // here (owned by the caller) and answers 202. The `register-token`\n // cmsHook binding then runs in SERVER mode, moves the token, and deletes\n // the claim. Usually done within seconds.\n push_token_claims: {\n singular: 'push_token_claim',\n ownerField: 'end_user',\n fields: {\n token: { type: 'string', required: true, indexSlot: 's1' },\n end_user: { type: 'string', required: true, indexSlot: 's2' },\n platform: { type: 'string', indexSlot: 's3', validation: { enum: ['ios', 'android', 'web'] } },\n locale: { type: 'string', indexSlot: 's4' },\n },\n },\n\n // The reminder ladder's per-user state. Owner-scoped: the app reads and\n // writes its OWN row (opt out, set quiet hours, stamp `last_active` and\n // reset `ladder_step` to 0 whenever the app comes to the foreground).\n push_prefs: {\n singular: 'push_pref',\n ownerField: 'end_user',\n fields: {\n end_user: { type: 'string', required: true, indexSlot: 's1', unique: true },\n // IANA zone ('Asia/Riyadh'); quiet hours are evaluated in it. An\n // unknown zone is treated as UTC.\n timezone: { type: 'string', indexSlot: 's2' },\n // false = no reminders at all (absent = reminders on).\n reminders: { type: 'bool' },\n // Local hours [quiet_start, quiet_end) during which nothing is sent;\n // the window may wrap midnight (22 \u2192 8). Equal values = no quiet hours.\n quiet_start: { type: 'int', validation: { min: 0, max: 23 } },\n quiet_end: { type: 'int', validation: { min: 0, max: 23 } },\n last_active: { type: 'datetime', indexSlot: 't1' },\n // The next rung (0..3) since `last_active` (the app resets it to 0; the\n // `ladder_step_default` derive writes 0 when a write leaves it out).\n ladder_step: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_reminded_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n\n // Where each cron left off (`key` = the job). No ownerField, so with\n // `strictEndUserScope` an end-user session cannot touch it at all.\n push_state: {\n singular: 'push_state',\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true },\n // the last item id walked; '' = start again from the newest row\n cursor: { type: 'string' },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n functions: {\n // THE REGISTRY \u2014 one bundle, five front doors (a function is code + a SET\n // of trigger bindings):\n // \u2022 http POST /v1/fn/register-token { token, user_id?, platform?, locale? }\n // server mode (your backend) re-owns at once; end-user mode\n // (the app) files a claim for a token someone else holds \u2192 202.\n // \u2022 cmsHook a new `push_token_claims` row \u2192 re-own it in SERVER mode,\n // delete the claim. `retry` re-delivers a failed attempt.\n // \u2022 webhook `auth.user.erased` (the auth erase), `user.erased` (the\n // admin erase) and `user.deleted` (the admin delete) \u2192 delete\n // every device row, claim and preferences row of that user.\n // NOT `auth.session.revoked`: that fires on every refresh-token\n // rotation, and would sign devices out of push at random.\n 'register-token': {\n entry: './functions/register-token.ts',\n trigger: { kind: 'http' },\n triggers: [\n { kind: 'cmsHook', collection: 'push_token_claims', event: 'beforeCreate', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'auth.user.erased', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'user.erased', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'user.deleted', retry: { maxAttempts: 3 } },\n ],\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // pure vxil-internal\n },\n\n // THE SENDER \u2014 two front doors:\n // \u2022 http POST /v1/fn/send-push { user_ids, messages, data? } \u2014 resolves\n // each user's live devices, picks the message for each device's\n // locale, and posts to the push service in batches.\n // \u2022 cron hourly: THE REMINDER LADDER. Users inactive 1 d / 3 d / 7 d get\n // at most one nudge per step (a user found already a week idle gets\n // only the 7-day one), never during their quiet hours, never after\n // opting out. Each step is claimed (If-Match) BEFORE the send, so\n // an overlapping tick can never send it twice.\n 'send-push': {\n entry: './functions/send-push.ts',\n trigger: { kind: 'http' },\n triggers: [{ kind: 'cron', schedule: '0 * * * *' }],\n scopes: ['cms:read', 'cms:write'],\n // Deny-by-default egress. `exp.host` is Expo's push host; `api.expo.dev`\n // is its newer alias. For raw FCM use 'fcm.googleapis.com'; for APNs,\n // 'api.push.apple.com' (and see README.md on the token-auth swap).\n egressAllow: ['exp.host', 'api.expo.dev'],\n // NO `secrets` here on purpose. Expo's push API needs no credential in its\n // default configuration, and secret resolution is FAIL-CLOSED: a declared\n // `secret:<name>` ref with no stored value makes every invocation\n // `409 secret_missing` before your code runs. Declare a secret only once\n // you will actually set it \u2014 see README.md \xA7\"If your push service needs a key\".\n },\n\n // \u2605 INVARIANT 3. THE GARBAGE COLLECTOR. A push service accepts a send and\n // only later tells you the device was gone \u2014 that verdict arrives as a\n // RECEIPT, not as the send response. Every 6 hours this cron trades parked\n // receipt ids for verdicts and deletes the rows the service declared dead.\n // It resumes where the last tick stopped (`push_state`), so a registry of\n // any size is walked end to end.\n 'prune-receipts': {\n entry: './functions/prune-receipts.ts',\n trigger: { kind: 'cron', schedule: '30 */6 * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: ['exp.host', 'api.expo.dev'],\n },\n },\n});\n",
18251
+ "readme": "# Push notifications (mobile)\n\nSending a push is the easy part: one HTTPS POST to your push service. What breaks in production is the\n**registry** \u2014 the mapping from a user to the devices they are currently holding.\n\nTokens rotate. Phones get sold. A user signs out and a colleague signs in on the same handset. Unless\nsomething reassigns that token, the previous owner's notifications keep arriving on someone else's lock\nscreen \u2014 a privacy incident, not a bug. And nobody collects delivery **receipts**, so the registry only\never grows: dead tokens are re-sent to forever and delivery rates look worse than they are.\n\nThis blueprint is that registry, with three invariants, plus the sending, the erasure clean-up and the\nre-engagement ladder every consumer app ends up writing. It is **the supported Expo path on vxil** \u2014\nvxil holds no push credential and runs no push service; your functions call Expo (which fronts APNs and\nFCM) through the egress allowlist. The whole mobile picture is in\n[guide 17 \u2014 vxil for Expo apps](../../docs/guide/17-mobile-apps-with-expo.md).\n\n```bash\nvxil init --template push-notifications\nvxil quickstart # or `vxil link <slug>` for an existing backend\nvxil push # four collections + three functions (two of them crons)\n```\n\nThree functions and two crons (the fastest hourly) on purpose: that is inside the Free plan's function\nlimits for staging and development projects (the per-plan table is in guide 12), so a free project runs\nall of it while you build the app. Every cron tick is bounded and resumable, so a tick the plan sheds is\nharmless \u2014 the next one picks up where the last stopped.\n\n## The three invariants\n\n| # | Invariant | Where it is enforced |\n|---|---|---|\n| **1** | **One token, one user** | `token: { unique: true }` \u2014 the *top-level* field attribute, backed by a claims table. A second registration of the same token is a `409 unique_violation`, atomically, with no read-then-write race. |\n| **2** | **Re-own on change** | `functions/register-token.ts` \u2014 on that 409 it looks the row up and **PATCHes the owner** with `If-Match`. Reassign, never duplicate. From the app itself (end-user mode) the move is **async**: a claim row + a server-mode `cmsHook` (below). |\n| **3** | **Prune stale** | `functions/prune-receipts.ts` \u2014 a cron every 6 hours trades parked receipt ids for verdicts and **deletes** every row the service reports as `DeviceNotRegistered`. It resumes where the last tick stopped, so the **whole** registry is walked, whatever its size. |\n\nThere is no `validation: { unique: true }` \u2014 that key does not exist and would be silently ignored.\nUniqueness is the top-level `unique` attribute, on scalar field types only.\n\n## The registry\n\n```ts\npush_tokens: {\n ownerField: 'end_user', // a signed-in device sees only its own rows\n fields: {\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' },\n platform: { type: 'string', indexSlot: 's3' }, // ios | android | web\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'pt-BR', \u2026\n receipt_id: { type: 'string' }, // the last send's pending verdict\n receipt_at: { type: 'datetime' }, // when it was parked (48 h \u2192 cleared)\n failures: { type: 'int', indexSlot: 'n1' },\n last_seen: { type: 'datetime', indexSlot: 't1' },\n checked_at: { type: 'datetime', indexSlot: 't2' },\n last_error: { type: 'text' },\n },\n}\n```\n\nBeside it: `push_token_claims` (the app's async re-own requests, owner-scoped), `push_prefs` (one row per\nuser for the reminder ladder, owner-scoped, `end_user` unique) and `push_state` (each cron's cursor \u2014 no\n`ownerField`, so no end-user session can touch it).\n\n`strictEndUserScope: true` is on, so a verified end-user session may only touch collections that declare\nan `ownerField`. `push_tokens` does \u2014 a device can refresh **its own** rows and structurally cannot\nenumerate anyone else's.\n\n> **The 256-character cap.** A unique value is capped at 256 characters. Expo tokens\n> (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside it. If you switch to raw **FCM\n> registration tokens**, store a hash in `token` and keep the full value in an unindexed `text` field \u2014\n> otherwise long tokens are rejected at write time.\n\n## Registering \u2014 from your backend or from the app\n\n`POST /v1/fn/register-token { token, user_id?, platform?, locale? }`\n\nInvariant 2 needs to reassign a token **away from another user**, which needs a tenant-wide read. A\nfunction invoked with a verified end-user session gets **owner-scoped** callback tokens \u2014 the platform\nstamps the principal onto every scoped token the function mints downstream \u2014 so in end-user mode the\nother user's row is simply invisible. That is the fail-safe working, not a gap. So there are two paths:\n\n| Called from | `user_id` | A token another user holds | Response |\n|---|---|---|---|\n| **your backend** (server key) | from the body | re-owned at once (`If-Match`) | `200 {action:'reowned', previous_owner}` |\n| **the app** (end-user session) | the **verified** session id \u2014 a body `user_id` is ignored | a **claim** row in `push_token_claims`, owned by the caller | `202 {action:'reown_queued', claim_id}` |\n\nEither way a new token is `201 {action:'created'}` and an unchanged owner is `200 {action:'refreshed'}`.\n\n**The async re-own.** `register-token` declares a second binding,\n`{ kind: 'cmsHook', collection: 'push_token_claims', event: 'beforeCreate', retry: { maxAttempts: 3 } }`.\nA platform-delivered hook carries **no** end-user, so it runs in server mode: it re-reads the claim,\nre-owns the token row for the claimant (or creates it, if the old holder deleted it meanwhile), and\ndeletes the claim. It usually lands within seconds; a failed attempt answers non-2xx and the `retry`\nre-delivers it, and every step is idempotent (a claim that is already gone is done).\n\n**What a claim can and cannot do.** A claim can only name its caller as the new owner (the platform\nstamps the verified owner on the row), and a push token is a bearer credential only the device itself is\nhanded by the OS. Claiming a token you do not hold moves **your** notifications to that device \u2014 no data\nreaches you. The trade-off to know about is availability: a signed-in user who somehow learned another\nuser's Expo token could move that device away from its owner, who then stops receiving pushes (security\nalerts included) until their app starts again and re-registers it. Tokens are not shown to other users\nby anything in this kit, so that needs the token to have leaked. If your app treats pushes as critical,\nadd a `validate` hook on `push_token_claims` that caps open claims, or have the app re-register on every\nforeground (it is idempotent \u2014 `200 {action:'refreshed'}` costs one write).\n\n**Sign-out.** A device that signs out should stop receiving the old user's pushes before anyone else\nsigns in: delete the device's own row from the app (`DELETE /v1/cms/items/push_tokens/{item_id}` with\nthe session \u2014 an owner may delete its own rows), or just let the next sign-in re-own it.\n\n## Erasure and deletion \u2014 `auth.user.erased`, not `session.revoked`\n\n`register-token` also binds `{ kind: 'webhook', source: 'auth.user.erased' }`,\n`{ kind: 'webhook', source: 'user.erased' }` (the admin `DELETE /v1/users/{id}?erase=true`) and\n`{ kind: 'webhook', source: 'user.deleted' }` (the admin delete without erase). That user loses every\n`push_tokens` row, pending claim and their `push_prefs` row (time zone, quiet hours, activity) \u2014 device\ntokens and preferences are personal data, vxil's erasure scrubs its own tables, not your collections,\nand a deleted account must stop receiving reminder nudges.\n\nA **merge or rekey** (`auth.user.merged` / `auth.user.rekeyed` \u2014 a guest becoming a registered user)\nneeds no binding: the app's next start registers its token under the new id, which re-owns the device.\nThe old id's prefs row goes quiet on its own (it has no devices left to nudge).\n\nDo **not** clean up on `auth.session.revoked`: it fires on every refresh-token rotation as well as on\nsign-out, so a cleanup bound to it would unregister devices at random.\n\n## Sending \u2014 localized per device, not per user\n\n`POST /v1/fn/send-push`\n\n```json\n{\n \"user_ids\": [\"usr_a\", \"usr_b\"],\n \"messages\": {\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\" }\n}\n```\n\nOne person can carry an English phone and an Arabic tablet. Because `locale` lives on the **token row**,\neach device gets the right copy with no extra lookup \u2014 exact match (`pt-BR`), then the base language\n(`pt`), then `default`.\n\nThe response distinguishes the two failure kinds, because they call for opposite actions:\n\n- an **immediate** `DeviceNotRegistered` \u2192 the token is dead now; the row is deleted;\n- anything else (rate limits, a message too big, a transient fault) \u2192 counted in `failures`, device kept.\n Deleting a device because one message was malformed is how a registry loses real users.\n- an **accepted** message returns a *receipt id*, not a delivery. That id is parked on the row, and the\n real verdict is collected by the cron below.\n\n## The receipts cron \u2014 the loop almost nobody closes\n\n`prune-receipts` runs at `30 */6 * * *`. It resumes the walk of `push_tokens` where the previous tick\nstopped (its cursor lives in `push_state`), collects the rows carrying a `receipt_id`, asks the push\nservice for those receipts in batches, and acts \u2014 `ok` clears the id, `DeviceNotRegistered` deletes the\nrow, anything else counts a failure and keeps the device. A receipt that is not ready yet is simply\nabsent from the response, which is not an error; it is asked again on the next walk. A receipt id parked\nfor more than 48 hours with no answer is cleared \u2014 the service no longer keeps it, and asking forever\nwould pin the row.\n\nEverything is bounded: one tick reads at most 2,000 rows and acts on at most 300, so its outbound calls\nstay in the low hundreds. Because the cursor persists, a registry of **any** size is walked end to end\nover successive ticks; when the walk reaches the oldest row the cursor resets and the next tick starts\nagain from the newest (rows registered during a walk are covered by the next one).\n\n> **A paging detail worth stealing.** The cron pages with `?cursor=` and **no** `?sort=`. In the\n> default order (newest first) the cursor is a plain item id, so it stays valid across ticks \u2014 persist it\n> and the walk resumes exactly where it stopped.\n\n**Never clears a newer receipt.** Each clear is a `PATCH` with `If-Match` on the row version the walk\nread. If `send-push` parked a newer receipt id on the same device in between, the clear is refused and\ncounted as `raced`; the newer id is checked on the next walk.\n\n## The reminder ladder \u2014 re-engagement that respects the user\n\n`send-push` has a second binding, `{ kind: 'cron', schedule: '0 * * * *' }`. Every hour it nudges users\nwho stopped opening the app: once after **1 day**, once after **3**, once after **7** \u2014 then silence until\nthey come back. The copy lives in the `LADDER` constant at the top of `functions/send-push.ts` (per\nlocale, picked per device like any send).\n\nIt reads `push_prefs`, a row per user **the app writes itself** (owner-scoped):\n\n| Field | Meaning |\n|---|---|\n| `reminders` | `false` = never. Absent = on. |\n| `timezone` | IANA zone (`Asia/Riyadh`); quiet hours are evaluated in it. Unknown = UTC. |\n| `quiet_start`, `quiet_end` | local hours `[start, end)` with nothing sent; may wrap midnight (`22` \u2192 `8`). Equal = none. |\n| `last_active` | stamp it whenever the app comes to the foreground\u2026 |\n| `ladder_step` | \u2026and reset this to `0` at the same time. The cron advances it. Left out on a write, a `derive` hook stores `0`. |\n\n```ts\n// in the app, on AppState 'active' (end-user session)\nawait vx.from('push_prefs').patch(prefsId, { last_active: new Date().toISOString(), ladder_step: 0 });\n```\n\nThree guarantees:\n\n- **Only idle users are read.** The walk filters on `last_active` and `ladder_step < 3` (both indexed),\n so active users and users who finished the ladder cost nothing; it resumes where the last tick stopped\n (`push_state`), reading at most 2,000 rows and nudging at most 100 users per tick. Users who opted out\n are still read (an absent `reminders` means on, which a filter cannot express) and skipped.\n- **Never twice, never a burst.** Each step is claimed on the prefs row with `If-Match` **before** the\n send \u2014 two overlapping ticks cannot both send it. A send that fails after the claim is a missed nudge,\n never a duplicate one. When several rungs are due at once (the kit added to a live app, reminders\n turned back on, a cron that was held), only the **highest** is sent and the ones below are skipped; and\n a rung waits its own gap after the previous nudge (`last_reminded_at`), so the 3-day and 7-day nudges\n stay two and four days after the one before.\n- **Never at night.** A user inside their quiet hours is skipped and picked up by a later tick once the\n window has passed (the next one, unless the walk spans more than one tick).\n\n## Example: post-purchase push on `payments.entitlement.changed`\n\nThe payments feature writes `payments.entitlement.changed` to your audit stream on every entitlement\ntransition, carrying `end_user_id`, the previous and new tier, `status`, `until`, `reason` and\n`environment`. Turning that into \"your Pro plan is live, in the language of the device\" is a wiring\nexercise, not new code \u2014 there are two shapes, both using the same `send-push` call:\n\n**(a) Subscribe and call in.** Outbound subscriptions are runtime rows, not config:\n\n```bash\ncurl -X POST https://api.vxil.com/v1/webhooks/subscriptions \\\n -H \"authorization: Bearer $VXIL_API_KEY\" -H 'content-type: application/json' \\\n -d '{\"target_url\":\"https://your-app.example.com/hooks/vxil\",\"event_prefixes\":[\"payments.entitlement.\"]}'\n```\n\nYour endpoint verifies the signed delivery, then calls `send-push` with a key holding\n`functions:invoke`:\n\n```ts\n// inside your webhook receiver \u2014 event.payload.end_user_id / .tier are the fields above\nawait fetch('https://api.vxil.com/v1/fn/send-push', {\n method: 'POST',\n headers: { authorization: `Bearer ${process.env.VXIL_API_KEY}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_ids: [event.payload.end_user_id],\n messages: {\n default: { title: 'You\\'re in', body: `${event.payload.tier} is active on this device.` },\n ar: { title: '\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644', body: `\u062A\u0645 \u062A\u0641\u0639\u064A\u0644 ${event.payload.tier} \u0639\u0644\u0649 \u0647\u0630\u0627 \u0627\u0644\u062C\u0647\u0627\u0632.` },\n },\n data: { screen: 'billing', tier: event.payload.tier },\n }),\n});\n```\n\n**(b) Drain the audit stream on a cron.** If you would rather keep everything inside your backend, the\nwatermark-plus-allow-list pattern in [`templates/alerts-to-slack`](../alerts-to-slack) reads the same\nevents with `GET /v1/audit/export?after_id=\u2026` and needs no public endpoint at all. Same events, no\ninbound surface.\n\nWhichever you pick, send on the **`from_tier \u2192 tier` crossing**, not on every event: entitlement snapshots\nare refolded on redelivery, and \"Pro is active\" three times is how an app gets its notifications muted.\n\n## Swapping the push service\n\nThe blueprint targets Expo because it fronts **both** APNs and FCM with one token format. To swap:\n\n| Service | `PUSH_URL` | `egressAllow` | Also change |\n|---|---|---|---|\n| Expo (default) | `https://exp.host/--/api/v2/push/send` | `exp.host`, `api.expo.dev` | \u2014 |\n| FCM (HTTP v1) | the HTTP v1 send method under `https://fcm.googleapis.com/v1/projects/<id>/` \u2014 the exact URL is in `functions/send-push.ts` | `fcm.googleapis.com` | one message per request; an OAuth bearer; the `message.notification` body shape; hash long tokens (see the 256-char cap above) |\n| APNs | `https://api.push.apple.com/3/device/<token>` | `api.push.apple.com` | one request per device; a JWT `authorization`; an `apns-topic` header; the `aps` payload shape |\n\n### If your push service needs a key\n\nAdd the ref to the function and set the value:\n\n```ts\n'send-push': { /* \u2026 */ secrets: ['secret:push_api_key'] }\n```\n```bash\nprintf '%s' \"$KEY\" | vxil secrets set functions/push_api_key\n```\n\nIt resolves per invocation as `envelope.secrets.push_api_key`, so **rotating it needs no redeploy**. One\nwarning: resolution is **fail-closed**. A declared `secret:<name>` ref with no stored value makes every\ninvocation `409 secret_missing` before your code runs \u2014 which is why this blueprint declares no secrets\nat all by default. Declare one only when you will actually set it.\n\n## What to learn from this\n\n- **The hard part of push is ownership, not delivery.** A `unique` field plus one re-own path is the\n whole difference between a registry that stays correct and one that leaks notifications between users.\n- **A verified principal beats a body field, always.** The platform hands the function `end_user.id`; the\n request body is a suggestion.\n- **Delete on the verdict, not on the error.** `DeviceNotRegistered` is the only signal that justifies\n removing a device. Everything else is about the message.\n- **What an end-user cannot do, a server-mode hook can \u2014 on the user's request.** The claim row is the\n request; the `cmsHook` is the authority. No backend of your own is needed for it.\n- **A cron that cannot finish in one tick must remember where it stopped.** Restarting from the top of\n a newest-first list means the oldest rows are never reached.\n- **One function, several front doors.** `triggers: [...]` puts an http door, a hook and a cron on the\n same bundle \u2014 the code that owns a job keeps all of its entry points.\n\n**Pairs with:** [`templates/alerts-to-slack`](../alerts-to-slack) for the audit-stream drain used in\nexample (b).\n",
17227
18252
  "functions": {
17228
- "prune-receipts.ts": "// prune-receipts.ts \u2014 THE GARBAGE COLLECTOR (a vxil function, cron trigger).\n//\n// Trigger: cron `30 3 * * *`.\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. page through `push_tokens`, 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//\n// Everything here is bounded: one cron tick reads at most MAX_ROWS rows and\n// makes at most MAX_ROWS / RECEIPT_BATCH outbound calls. A registry larger than\n// that is simply pruned over several nights, which is fine \u2014 a dead token costs\n// one wasted message a day, not correctness.\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/** cms page size \u2014 100 is the default `maxPageSize` for the cms feature. */\nconst PAGE = 100;\n\ntype Env = CronFunctionEnvelope;\ninterface TokenRow {\n item_id: string;\n data: { token?: string; receipt_id?: string; failures?: number };\n}\ninterface Receipt { status?: string; message?: string; details?: { error?: 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. 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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const pending = new Map<string, TokenRow>(); // receipt_id \u2192 row\n let cursor: string | null = null;\n let scanned = 0;\n while (scanned < MAX_ROWS) {\n // NOTE: no `sort=` here on purpose. cms cursor pagination works with the\n // DEFAULT sort only \u2014 pairing `?cursor=` with a `?sort=` is a clean 422\n // (`cursor pagination works with the default sort only`), and `next_cursor`\n // is not even emitted for a custom sort. 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 for (const it of items) {\n scanned++;\n if (it.data.receipt_id) pending.set(it.data.receipt_id, it);\n }\n cursor = body.data?.next_cursor ?? null;\n if (!cursor || items.length === 0) break;\n }\n if (pending.size === 0) return Response.json({ scanned, checked: 0, pruned: 0 });\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 const errors: string[] = [];\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 tomorrow.\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 if (!receipt) continue; // not ready \u2014 leave the receipt_id in place\n const row = pending.get(id)!;\n checked++;\n if (receipt.status === 'ok') {\n // Delivered. Nothing more to check for this device.\n await patch(base, H, row.item_id, { receipt_id: '', failures: 0, checked_at: iso() });\n cleared++;\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 await patch(base, H, row.item_id, {\n receipt_id: '',\n failures: (row.data.failures ?? 0) + 1,\n last_error: String(reason).slice(0, 300),\n checked_at: iso(),\n });\n errors.push(reason);\n }\n }\n\n return Response.json({ scanned, pending: pending.size, checked, pruned, cleared, errors: errors.slice(0, 10) });\n },\n};\n\nasync function patch(base: string, H: Record<string, string>, itemId: string, data: Record<string, unknown>) {\n await fetch(`${base}/v1/cms/items/push_tokens/${itemId}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data }),\n }).catch(() => null);\n}\nconst iso = () => new Date().toISOString();\n",
17229
- "register-token.ts": "// register-token.ts \u2014 THE DEVICE REGISTRY (a vxil function, http trigger).\n//\n// POST /v1/fn/register-token { token, user_id, platform?, locale? }\n//\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, which is\n// why the re-own path lives here: see README.md.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = HttpFunctionEnvelope<{ token?: string; user_id?: string; platform?: string; locale?: string }>;\ninterface TokenRow { item_id: string; version?: number; data: { token?: string; end_user?: string } }\n\nconst PLATFORMS = new Set(['ios', 'android', 'web']);\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\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 owner = env.end_user?.id ?? (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 now = new Date().toISOString();\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const data: Record<string, unknown> = {\n token, end_user: owner, last_seen: now, failures: 0,\n ...(p.platform && PLATFORMS.has(p.platform) ? { platform: p.platform } : {}),\n ...(p.locale ? { locale: String(p.locale).slice(0, 16) } : {}),\n };\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) {\n return json({ error: 'register_failed', status: created.status }, 502);\n }\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, and the reason this\n // function is documented as a server call.\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 json({ error: 'lookup_failed', status: found.status }, 502);\n const row = ((await found.json()) as { data?: { items?: TokenRow[] } }).data?.items?.[0];\n if (!row) {\n // Unique says it exists, but this caller cannot see it \u21D2 it belongs to\n // another user and we are owner-scoped. Say so honestly.\n return json({\n registered: false,\n error: 'token_owned_by_another_user',\n hint: 'invoke register-token in server mode to reassign it',\n }, 409);\n }\n\n const previous = row.data.end_user;\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.\n const patched = 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: { ...data, last_error: '' } }),\n });\n if (!patched.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: patched.status }, 409);\n }\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\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
17230
- "send-push.ts": `// send-push.ts \u2014 THE SENDER (a vxil function, http trigger).
17231
- //
17232
- // POST /v1/fn/send-push
17233
- // {
17234
- // "user_ids": ["usr_a", "usr_b"],
17235
- // "messages": { // keyed by locale; "default" is required
17236
- // "default": { "title": "You're in", "body": "Pro is active on this device." },
17237
- // "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." },
17238
- // "fr": { "title": "C'est actif", "body": "Pro est activ\xE9 sur cet appareil." }
17239
- // },
17240
- // "data": { "screen": "billing" } // optional payload handed to your app
17241
- // }
17242
- //
17243
- // It resolves each user's live devices, picks the copy for THAT DEVICE'S locale
17244
- // (captured at registration), and posts to the push service in batches.
17245
- //
17246
- // LOCALIZATION IS PER DEVICE, NOT PER USER. One person can carry an English
17247
- // phone and an Arabic tablet. Because \`locale\` lives on the token row, the right
17248
- // copy goes to each device with no extra lookup.
17249
- //
17250
- // Two failure kinds, handled differently:
17251
- // \u2022 an IMMEDIATE per-message error (\`DeviceNotRegistered\`) \u2014 the token is dead
17252
- // right now; delete the row.
17253
- // \u2022 an accepted message \u2014 the service returns a RECEIPT id, and the real
17254
- // verdict arrives later. Store the id; \`prune-receipts\` collects it.
17255
-
17256
- // Expo's push endpoint. For raw FCM: 'https://fcm.googleapis.com/v1/projects/<id>/messages:send'
17257
- // (and an OAuth bearer). For APNs: 'https://api.push.apple.com/3/device/<token>'
17258
- // (one request per device, a JWT \`authorization\`, and an \`apns-topic\` header).
17259
- // Whichever you pick, its HOST must be in the function's \`egressAllow\`.
17260
-
17261
- import type { HttpFunctionEnvelope } from '@vxil/sdk';
17262
-
17263
- const PUSH_URL = 'https://exp.host/--/api/v2/push/send';
17264
- /** Expo accepts up to 100 messages in one request. */
17265
- const BATCH = 100;
17266
- /** Devices resolved per invocation \u2014 a guardrail, not a limit of the platform. */
17267
- const MAX_DEVICES = 1000;
17268
-
17269
- type Env = HttpFunctionEnvelope<{
17270
- user_ids?: string[];
17271
- messages?: Record<string, { title?: string; body?: string }>;
17272
- data?: Record<string, unknown>;
17273
- }>;
17274
- interface TokenRow {
17275
- item_id: string;
17276
- version?: number;
17277
- data: { token?: string; end_user?: string; locale?: string; failures?: number };
17278
- }
17279
- /** One entry of Expo's response array, positionally matched to the request. */
17280
- interface Ticket { status?: string; id?: string; message?: string; details?: { error?: string } }
17281
-
17282
- export default {
17283
- async fetch(req: Request): Promise<Response> {
17284
- const env = (await req.json().catch(() => ({}))) as Env;
17285
- const base = env.vxil_base ?? 'https://api.vxil.com';
17286
- const cms = env.scoped_jwts?.cms;
17287
- if (!cms) return json({ error: 'missing cms scope' }, 403);
17288
-
17289
- const p = env.payload ?? {};
17290
- const userIds = Array.isArray(p.user_ids) ? p.user_ids.filter((u) => typeof u === 'string' && u) : [];
17291
- const messages = p.messages ?? {};
17292
- if (userIds.length === 0) return json({ error: 'user_ids is required' }, 400);
17293
- if (!messages.default?.title && !messages.default?.body) {
17294
- return json({ error: 'messages.default { title, body } is required' }, 400);
17295
- }
17296
-
17297
- const H = { authorization: \`Bearer \${cms}\`, 'content-type': 'application/json' };
17298
-
17299
- // \u2500\u2500 1. Resolve devices. One filtered read per user (owner-scoped by value,
17300
- // not by principal \u2014 this runs in server mode). \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
17301
- const devices: TokenRow[] = [];
17302
- for (const uid of userIds) {
17303
- if (devices.length >= MAX_DEVICES) break;
17304
- const filter = encodeURIComponent(JSON.stringify({ end_user: uid }));
17305
- const res = await fetch(\`\${base}/v1/cms/items/push_tokens?filter=\${filter}&limit=50\`, { headers: H });
17306
- if (!res.ok) continue;
17307
- const items = ((await res.json()) as { data?: { items?: TokenRow[] } }).data?.items ?? [];
17308
- for (const it of items) if (it.data.token) devices.push(it);
17309
- }
17310
- if (devices.length === 0) return json({ sent: 0, devices: 0, note: 'no registered devices' }, 200);
17311
-
17312
- // \u2500\u2500 2. Build one message per device, in that device's language. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
17313
- const outgoing = devices.map((d) => {
17314
- const copy = pick(messages, d.data.locale);
17315
- return {
17316
- to: d.data.token!,
17317
- ...(copy.title ? { title: copy.title } : {}),
17318
- ...(copy.body ? { body: copy.body } : {}),
17319
- ...(p.data ? { data: p.data } : {}),
17320
- sound: 'default',
17321
- };
17322
- });
17323
-
17324
- // \u2500\u2500 3. Send in batches; reconcile each ticket back to its device by index. \u2500
17325
- let accepted = 0;
17326
- let dropped = 0;
17327
- const errors: string[] = [];
17328
- for (let i = 0; i < outgoing.length; i += BATCH) {
17329
- const slice = outgoing.slice(i, i + BATCH);
17330
- const res = await fetch(PUSH_URL, {
17331
- method: 'POST',
17332
- headers: { 'content-type': 'application/json', accept: 'application/json' },
17333
- body: JSON.stringify(slice),
17334
- }).catch(() => null);
17335
- if (!res || !res.ok) {
17336
- errors.push(\`push service returned \${res?.status ?? 'network error'} for batch \${i / BATCH}\`);
17337
- continue;
17338
- }
17339
- const tickets = (((await res.json().catch(() => ({}))) as { data?: Ticket[] }).data) ?? [];
17340
- for (let k = 0; k < slice.length; k++) {
17341
- const device = devices[i + k]!;
17342
- const ticket = tickets[k];
17343
- if (ticket?.status === 'ok' && ticket.id) {
17344
- // Accepted, verdict pending. Park the receipt id for prune-receipts.
17345
- accepted++;
17346
- await patch(base, H, device, { receipt_id: ticket.id, failures: 0, last_error: '' });
17347
- continue;
17348
- }
17349
- const reason = ticket?.details?.error ?? ticket?.message ?? 'unknown';
17350
- if (reason === 'DeviceNotRegistered') {
17351
- // Definitive: this token will never deliver again. \u2605 INVARIANT 3.
17352
- await del(base, H, device);
17353
- dropped++;
17354
- continue;
17355
- }
17356
- // Anything else (MessageTooBig, MessageRateExceeded, a transient fault)
17357
- // is NOT a reason to delete a device. Count it and move on.
17358
- await patch(base, H, device, {
17359
- failures: (device.data.failures ?? 0) + 1,
17360
- last_error: String(reason).slice(0, 300),
17361
- });
17362
- errors.push(reason);
17363
- }
17364
- }
17365
-
17366
- return json({ devices: devices.length, accepted, dropped, errors: errors.slice(0, 10) }, 200);
17367
- },
17368
- };
17369
-
17370
- /** Locale match: exact ('pt-BR'), then the base language ('pt'), then default. */
17371
- function pick(
17372
- messages: Record<string, { title?: string; body?: string }>,
17373
- locale: string | undefined,
17374
- ): { title?: string; body?: string } {
17375
- if (locale) {
17376
- if (messages[locale]) return messages[locale]!;
17377
- const bare = locale.split('-')[0]!;
17378
- if (messages[bare]) return messages[bare]!;
17379
- }
17380
- return messages.default ?? {};
17381
- }
17382
-
17383
- async function patch(base: string, H: Record<string, string>, d: TokenRow, data: Record<string, unknown>) {
17384
- await fetch(\`\${base}/v1/cms/items/push_tokens/\${d.item_id}\`, {
17385
- method: 'PATCH', headers: H, body: JSON.stringify({ data }),
17386
- }).catch(() => null);
17387
- }
17388
- async function del(base: string, H: Record<string, string>, d: TokenRow) {
17389
- await fetch(\`\${base}/v1/cms/items/push_tokens/\${d.item_id}\`, { method: 'DELETE', headers: H }).catch(() => null);
17390
- }
17391
- const json = (o: unknown, status: number) => Response.json(o, { status });
17392
- `
18253
+ "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",
18254
+ "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",
18255
+ "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 50 ms on the Free plan). */\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"
17393
18256
  }
17394
18257
  },
17395
18258
  {
@@ -17412,7 +18275,7 @@ const json = (o: unknown, status: number) => Response.json(o, { status });
17412
18275
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// \"Alerts to Slack\" \u2014 failure events \u2192 one chat message per state crossing.\n//\n// There is no vxil \"connectors\" feature and no alert-routing engine, on purpose:\n// every feature already writes its lifecycle to your tenant's audit stream, and\n// a function can read that stream. This blueprint is DISTRIBUTION over that\n// spine \u2014 one cron function, one cms collection as its memory, one BYO webhook:\n// \u2022 cms \u2192 `alert_state`: the watermark + per-condition state (ok|stale|\n// broken|could_not_check) that makes alerts once-per-crossing\n// \u2022 functions \u2192 `alerts`: drains the audit stream past the watermark every\n// 5 minutes, matches an allow-list of failure events, posts\n// to Slack (or Discord) through the egress allowlist\n// A permanently-red condition therefore produces ONE message when it turns red,\n// ONE when it recovers, and (optionally) a reminder every REPEAT_AFTER_HOURS.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {},\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n // The function's memory. One row per alert KEY (e.g. `payments:ingest:stripe`,\n // `jobs:dead-letter:notifications.deliver`, `functions:checkout`) plus the\n // reserved `__cursor__` row that holds the audit-stream watermark.\n alert_state: {\n singular: 'alert_state',\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true }, // unique \u21D2 409 on a racing duplicate\n state: { type: 'string', indexSlot: 's2' }, // ok | stale | broken | could_not_check\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n event: { type: 'string' }, // the last audit event name seen for this key\n detail: { type: 'text' }, // last reason/error text (truncated)\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n last_event_id: { type: 'string' },\n last_alert_at: { type: 'datetime', indexSlot: 't1' },\n updated_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n },\n },\n\n functions: {\n // The whole blueprint is this one function. Cron every 5 minutes; also\n // invocable by hand (`vxil functions invoke alerts --data '{\"test\":true}'`)\n // to prove the webhook is wired without touching the watermark.\n alerts: {\n entry: './functions/alerts.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' },\n scopes: ['cms:read', 'cms:write'],\n // Two BYO secrets, resolved per invocation and injected as env.secrets.<name>:\n // slack_webhook_url \u2014 the Slack incoming-webhook URL (or a Discord webhook,\n // or a Google Chat space webhook)\n // vxil_read_key \u2014 an API key of YOUR tenant holding ONLY `features:read`;\n // the audit stream is not reachable through the\n // function's scoped callback, so the drain reads it with\n // this least-privilege key instead\n secrets: ['secret:slack_webhook_url', 'secret:vxil_read_key'],\n // Deny-by-default egress: only the chat host (add 'discord.com' for Discord,\n // 'chat.googleapis.com' for a Google Chat space webhook).\n egressAllow: ['hooks.slack.com'],\n },\n },\n\n secrets: {\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord / Google Chat space webhook) URL' },\n vxil_read_key: { feature: 'functions', description: 'a vxil API key with only features:read \u2014 reads the audit stream' },\n },\n});\n",
17413
18276
  "readme": "# Alerts to Slack (ops)\n\nFailure events from your backend \u2192 **one Slack (or Discord) message per state crossing**, with a\n\"resolved\" message when the condition clears. No connectors registry, no alert-routing feature \u2014 one\ncron function that drains your tenant's **audit stream** past a watermark, an allow-list of failure\nevents it cares about, and a `cms` collection as its memory so a permanently-red condition never\nspams the channel.\n\n```bash\nvxil init --template alerts-to-slack\nvxil quickstart # or `vxil link <slug>` for an existing backend\nprintf '%s' \"$SLACK_URL\" | vxil secrets set functions/slack_webhook_url\nprintf '%s' \"$READ_KEY\" | vxil secrets set functions/vxil_read_key\nvxil push # collection + the `alerts` cron function\nvxil functions invoke alerts --data '{\"test\":true}' # posts a test message\n```\n\n**The two secrets** (references in the config, values only ever in the encrypted secret store):\n\n| Secret | What it is | Where it comes from |\n|---|---|---|\n| `slack_webhook_url` | a Slack *incoming webhook* URL (or a Discord webhook URL, or a Google Chat *space webhook* URL) | Slack \u2192 Apps \u2192 Incoming Webhooks; Discord \u2192 channel \u2192 Integrations \u2192 Webhooks; Google Chat \u2192 space \u2192 Apps & integrations \u2192 Webhooks |\n| `vxil_read_key` | an API key **of this tenant** holding **only `features:read`** | dashboard \u2192 API keys \u2192 create, tick `features:read` and nothing else |\n\nWhy a key at all? The audit stream (`GET /v1/audit` and its NDJSON export) is a control-plane read that is\n**not** on a function's scoped callback (the callback covers the feature APIs \u2014 cms, payments, jobs\u2026),\nso the drain reads it with the narrowest key that can: read-only, no write scope, revocable in one click.\nFor Discord, add `'discord.com'` to `egressAllow` \u2014 the function sends both Slack's `text` and Discord's\n`content` field, so one message body works on either.\nFor Google Chat, add `'chat.googleapis.com'` to `egressAllow` \u2014 a space webhook takes `text` alone, and the\nfunction sends only that field to that host.\n\n## What it does, every 5 minutes\n\n1. **Watermark.** Reads the `__cursor__` row in `alert_state`. On the very first run it stores the\n *newest* audit id and stops \u2014 history never becomes an alert storm.\n2. **Drain.** `GET /v1/audit/export?after_id=<cursor>&limit=500` (ascending NDJSON), up to 5 pages a tick.\n3. **Match.** Each row is looked up in `RULES` (the allow-list in `functions/alerts.ts`). Anything not\n listed is ignored. A match becomes `(key, state, level)` \u2014 e.g. a `payments.webhook_event.failed`\n row with `provider: stripe, state: broken` \u2192 key `payments:ingest:stripe`, state `broken`.\n4. **Compare + decide.** One `alert_state` row per key. Only a **crossing** posts:\n - not-ok while the row said ok (or no row yet) \u2192 \u{1F534}/\u{1F7E0} alert\n - ok while the row said not-ok \u2192 \u{1F7E2} \"RESOLVED\"\n - same state again \u2192 silence (a \u23F0 reminder after `REPEAT_AFTER_HOURS`, default 24; `0` disables)\n5. **Advance** the watermark with `If-Match` on the cursor row's version. Cron deliveries are\n at-least-once; if two ticks overlap, the first to move the watermark wins, and the `unique` key plus\n `If-Match` on every state row mean a racing run gets a 409 and stands down instead of double-posting.\n\n## The events it listens for\n\nEvery name in the table below is an audit event a vxil feature writes **today**. Keys are what collapse\nrepeats: ten dead letters of the same job are one key, one row, one alert.\n\n| Audit event | Level | Key (one row each) | State |\n|---|---|---|---|\n| `payments.webhook_event.failed` | from payload (`error`) | `payments:ingest:<provider>` | from payload (`broken`) |\n| `payments.webhook.rejected` | from payload (`warn`) | `payments:webhook-rejected:<provider>` | from payload (`broken`) |\n| `payments.grant.failed` | from payload (`error`) | `payments:grant:<credit_type>:<source>` | from payload (`broken`) |\n| `payments.subscription.past_due` | warn | `payments:subscription:<end_user_id>` | broken |\n| `payments.subscription.active` | info | `payments:subscription:<end_user_id>` | **ok** \u2192 posts RESOLVED |\n| `payments.charge.disputed` | warn | `payments:disputes:<provider>` | broken |\n| `job.dead_lettered` | error | `jobs:dead-letter:<job_name>` | broken |\n| `job.dead_letter_quota_exceeded` | error | `jobs:dead-letter-quota` | broken |\n| `job.generation.failed` | error | `jobs:generation:<error_class>` | broken |\n| `functions.quarantined` | error | `functions:<name>` | broken |\n| `functions.quarantine.cleared` | info | `functions:<name>` | **ok** \u2192 posts RESOLVED |\n| `functions.deploy.denied` | warn | `functions:deploy-denied` | broken |\n| `notifications.delivery.dead_lettered` | from payload (`error`) | `notifications:dead-letter:<template_id>` | from payload (`broken`) |\n| `webhooks.delivery.dead_lettered` | from payload (`error`) | `webhooks:delivery:<subscription_id>` | from payload (`broken`) |\n| `webhooks.delivery.recovered` | from payload (`info`) | `webhooks:delivery:<subscription_id>` | **ok** \u2192 posts RESOLVED |\n| `webhooks.inbound.rejected` | from payload (`warn`) | `webhooks:inbound:<source_id>:<reason>` | from payload (`broken`) |\n| `jobs.schedule.missed` | from payload (`warn`) | `jobs:schedule:<schedule_id>` | from payload (**`stale`** \u2014 late, not broken) |\n| `jobs.schedule.recovered` | from payload (`info`) | `jobs:schedule:<schedule_id>` | **ok** \u2192 posts RESOLVED |\n| `functions.run.failed` | from payload (`error`) | `functions:run:<name>` | from payload (`broken`) |\n| `functions.run.recovered` | from payload (`info`) | `functions:run:<name>` | **ok** \u2192 posts RESOLVED |\n\n**Levels differ by event, and the table respects that.** The payments failure events and every\nlifecycle failure event (the last nine rows) carry their own `level` and `state` in the payload, so those\nrules defer to the event. The older `job.*` and `functions.quarantine*` events supply both from the rule.\n\n**Five pairs close their own alerts.** `past_due` \u2192 `active`, `quarantined` \u2192 `quarantine.cleared`,\n`webhooks.delivery.dead_lettered` \u2192 `.recovered`, `jobs.schedule.missed` \u2192 `.recovered` and\n`functions.run.failed` \u2192 `.recovered` share a key, so a recovery posts RESOLVED with no extra rule. Events with no natural \"ok\" \u2014 a dead\nletter \u2014 stay red until the reminder window, which is what `REPEAT_AFTER_HOURS` is for.\n\n**The same failure can arrive twice, on purpose.** Notification sends and outbound webhook deliveries\nride the jobs substrate, so a dead letter also arrives as `job.dead_lettered` with `job_name` set to\n`notifications.deliver` / `webhooks.deliver`. The dedicated `*.dead_lettered` events carry the\nfeature-level context (template id, subscription id, target host) that the generic job row lacks; keep\nboth rules, or drop `job.dead_lettered` if you only want the feature-level view.\n\n**Own the table.** `RULES` is data: add an event name, decide its key and level, push.\n\n## The message\n\n```\n\u{1F534} [ERROR] payments ingest (stripe) \u2192 BROKEN \u2014 signature verification failed (at 2026-09-10T06:00:12Z)\n\u{1F534} [ERROR] job dead-lettered: notifications.deliver \u2192 BROKEN (at 2026-09-10T06:03:44Z)\n\u23F0 STILL BROKEN: function quarantined: checkout (since 2026-09-09T06:01:02Z)\n\u{1F7E2} RESOLVED: payments ingest (stripe) is back to ok (was broken)\n```\n\n## What to learn from this\n\n- **The audit stream is the event spine.** Every feature writes its lifecycle there; a webhook\n subscription fans it out, and a function can drain it. Nothing here needed a new primitive.\n- **Once-per-crossing suppression needs memory \u2014 a cms collection is that memory.** `unique` on `key`\n and `If-Match` on every write make it correct under the at-least-once cron, not just usually right.\n- **Least privilege has a shape.** The function talks to cms through its scoped callback, reads the\n audit stream with a `features:read`-only key it holds as a secret, and can reach exactly one external\n host. Rotate either secret without a redeploy \u2014 refs resolve per invocation.\n\n**Latency:** a failure is posted within one cron interval (5 minutes) of the audit row landing.\n**Pairs with:** `templates/payments-heartbeat/` (the silence detector that emits its own crossings) and\n`templates/push-notifications/`.\n",
17414
18277
  "functions": {
17415
- "alerts.ts": "// alerts.ts \u2014 FAILURE EVENTS \u2192 ONE CHAT MESSAGE PER STATE CROSSING (a vxil function).\n//\n// Trigger: cron `*/5 * * * *`. Each run:\n// 1. reads the watermark (the `__cursor__` row in `alert_state`; first run = \"now\",\n// so history never produces an alert storm),\n// 2. drains the tenant audit stream past it \u2014 `GET /v1/audit/export?after_id=\u2026`\n// (ascending, NDJSON) with the `vxil_read_key` secret (a key holding ONLY\n// `features:read`; the audit stream is not on the scoped callback),\n// 3. matches each row against RULES (the allow-list below) and turns it into a\n// (key, state, level) \u2014 e.g. `payments:ingest:stripe` \u2192 `broken`,\n// 4. compares with the stored state for that key (cms, one row per key, `unique`\n// key \u21D2 a racing run gets a 409 and stands down) and posts ONLY on a crossing:\n// ok\u2192red = alert, red\u2192ok = \"resolved\", red\u2192red = silence (or a reminder after\n// REPEAT_AFTER_HOURS),\n// 5. advances the watermark (If-Match on the cursor row's version \u2014 a concurrent\n// run that already advanced it wins; jobs may deliver a cron tick twice).\n//\n// Manual check: `vxil functions invoke alerts --data '{\"test\":true}'` posts a test\n// message and touches nothing else.\n//\n// Tune the constants; own the RULES table \u2014 it is data, not a routing engine.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst REPEAT_AFTER_HOURS = 24; // remind about a still-red condition this often; 0 = never\nconst MAX_PAGES_PER_RUN = 5; // \xD7 500 audit rows \u2014 bounds one cron tick\nconst MAX_POSTS_PER_RUN = 20; // a burst of distinct failures collapses into one summary line\nconst DETAIL_MAX = 240;\n\ntype Level = 'info' | 'warn' | 'error';\ntype State = 'ok' | 'stale' | 'broken' | 'could_not_check';\ntype Payload = Record<string, unknown>;\n\ninterface Rule {\n /** A literal level, or 'payload' to take `payload.level` (the payments failure\n * events carry one; the jobs/functions ones do not). */\n level: Level | 'payload';\n key: (p: Payload) => string; // one state row per key \u2014 this is what suppresses repeats\n title: (p: Payload) => string;\n /** A literal state, or omitted to take `payload.state` (falling back to 'broken'). */\n state?: State;\n}\n\nconst str = (v: unknown, fallback = 'unknown') => (typeof v === 'string' && v ? v : fallback);\n\n/** THE ALLOW-LIST. Every name below is an audit event a vxil feature writes;\n * anything not listed is ignored by the drain. The KEY is what collapses\n * repeats: ten dead letters of the same job are one key, one row, one alert.\n *\n * Every name below is emitted today (2026-09-10). A rule for an event nobody\n * emits would simply never match \u2014 so if you add one, verify the emitter first.\n */\nconst RULES: Record<string, Rule> = {\n // \u2500\u2500 payments \u2014 the money path. A silent failure here is lost revenue. \u2500\u2500\u2500\u2500\u2500\u2500\n // These three carry `level` (info|warn|error) and `state` (ok|stale|broken|\n // could_not_check) in their payload, so the rule defers to the event.\n 'payments.webhook_event.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:ingest:${str(p.provider, 'all')}`,\n title: (p) => `payments ingest (${str(p.provider, 'all providers')})`,\n },\n 'payments.webhook.rejected': {\n level: 'payload', // emitted as warn/broken\n key: (p) => `payments:webhook-rejected:${str(p.provider)}`,\n title: (p) => `payments webhook rejected (${str(p.provider)})`,\n },\n // NOTE: grant.failed carries NO `provider` \u2014 it is keyed on the credit type\n // and the grant source instead (end_user_id/credit_type/amount/grant_key/\n // source/error). Keying on a field an event does not carry would collapse\n // every provider into one bucket named \"unknown\".\n 'payments.grant.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:grant:${str(p.credit_type, 'credits')}:${str(p.source, 'webhook')}`,\n title: (p) => `entitlement grant failed (${str(p.credit_type, 'credits')} via ${str(p.source, 'webhook')})`,\n },\n // Dunning. `past_due` is the red crossing and `active` is its RESOLVED twin \u2014\n // the same key, so a recovered subscription closes its own alert. Both are\n // emitted at level info, so the rule forces the level it wants.\n 'payments.subscription.past_due': {\n level: 'warn',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'broken',\n },\n 'payments.subscription.active': {\n level: 'info',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'ok',\n },\n // A dispute is a chargeback in progress \u2014 money already collected, now at risk.\n 'payments.charge.disputed': {\n level: 'warn',\n key: (p) => `payments:disputes:${str(p.provider)}`,\n title: (p) => `charge disputed (${str(p.provider)})`,\n state: 'broken',\n },\n\n // \u2500\u2500 jobs \u2014 the delivery substrate. Notification and outbound-webhook sends\n // ride jobs, so THEIR dead letters arrive here too, under `job_name`\n // ('notifications.deliver', 'webhooks.deliver', 'fn-cron:<name>', \u2026).\n // These payloads carry run_id/attempt/job_name and NO level or state, so\n // the rule supplies both. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 'job.dead_lettered': {\n level: 'error',\n key: (p) => `jobs:dead-letter:${str(p.job_name)}`,\n title: (p) => `job dead-lettered: ${str(p.job_name)}`,\n state: 'broken',\n },\n 'job.dead_letter_quota_exceeded': {\n level: 'error',\n key: () => 'jobs:dead-letter-quota',\n title: () => 'daily dead-letter quota exceeded',\n state: 'broken',\n },\n 'job.generation.failed': {\n level: 'error',\n key: (p) => `jobs:generation:${str(p.error_class, 'failed')}`,\n title: (p) => `generation job failed (${str(p.error_class, 'unclassified')})`,\n state: 'broken',\n },\n\n // \u2500\u2500 functions \u2014 the breaker. Consecutive failures quarantine a function;\n // clearing the quarantine is the ok crossing on the SAME key, so this pair\n // produces exactly one \u{1F534} and one \u{1F7E2}. Payload: name (+ consecutive_failures).\n 'functions.quarantined': {\n level: 'error',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'broken',\n },\n 'functions.quarantine.cleared': {\n level: 'info',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'ok',\n },\n 'functions.deploy.denied': {\n level: 'warn',\n key: () => 'functions:deploy-denied',\n title: (p) => `function deploy denied (needs the ${str(p.required_tier)} tier)`,\n state: 'broken',\n },\n\n // \u2500\u2500 The lifecycle failure events (live since 2026-09-10). Every one of these\n // carries `level` + `state` in its payload (the platform's failure-event\n // vocabulary gate enforces it), so the rules defer to the event. Each\n // failure/recovery pair shares a key, so a recovery posts RESOLVED. \u2500\u2500\u2500\u2500\u2500\u2500\n 'notifications.delivery.dead_lettered': {\n level: 'payload', // emitted as error/broken; payload: delivery_id, template_id, error_class, attempts\n key: (p) => `notifications:dead-letter:${str(p.template_id, 'all')}`,\n title: (p) => `notification delivery dead-lettered (${str(p.template_id, 'all templates')})`,\n },\n 'webhooks.delivery.dead_lettered': {\n level: 'payload', // error/broken; payload: subscription_id, target_host, attempts, error_class\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.delivery.recovered': {\n level: 'payload', // info/ok \u2014 same key as the dead-letter \u2192 RESOLVED\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.inbound.rejected': {\n level: 'payload', // warn/broken; payload: source_id, provider, reason, fingerprint (never the body)\n key: (p) => `webhooks:inbound:${str(p.source_id)}:${str(p.reason)}`,\n title: (p) => `inbound webhook rejected (${str(p.provider, 'source')} ${str(p.source_id)}: ${str(p.reason)})`,\n },\n 'jobs.schedule.missed': {\n level: 'payload', // warn/STALE \u2014 a late tick is stale, not broken; payload: schedule_id, job_name, late_seconds\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n 'jobs.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED on the next on-time fire\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n // A function's cron trigger that produced NO run for 2\xD7 its interval \u2014 the\n // platform's silent-schedule watch (error/stale), and the recovery on the same\n // key. Payload: function, schedule_id, cron, last_run_at, expected_by.\n 'functions.schedule.missed': {\n level: 'payload',\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))} (last run ${str(p.last_run_at, 'never')})`,\n },\n 'functions.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED when a run appears again\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))}`,\n },\n 'functions.run.failed': {\n level: 'payload', // error/broken; payload: name, trigger, run_id, error_class\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n 'functions.run.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n};\n\n// the schedule tick, or a hand invoke (`POST /v1/fn/alerts`) carrying `{ test: true }`\ntype Envelope = CronFunctionEnvelope | HttpFunctionEnvelope<{ test?: boolean }>;\ninterface AuditRow { id: string | number; event: string; payload?: Payload; created_at?: string }\ninterface StateData {\n key: string; state?: State; level?: Level; event?: string; detail?: string;\n cursor?: string; last_event_id?: string; last_alert_at?: string; updated_at?: string;\n}\ninterface Item { item_id: string; version?: number; data: StateData }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const hook = env.secrets?.slack_webhook_url;\n const readKey = env.secrets?.vxil_read_key;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n if (!hook || !readKey) return Response.json({ error: 'missing secrets' }, { status: 409 });\n\n // Manual wiring check \u2014 posts, touches nothing else.\n if (env.payload?.test) {\n const r = await post(hook, `\u{1F7E2} [INFO] alerts-to-slack is wired (test message)`);\n return Response.json({ test: true, posted: r.ok, status: r.status });\n }\n\n const store = new Store(base, cms);\n\n // 1. the watermark (first run: start at the newest audit id \u2014 no history storm)\n const cursorRow = await store.byKey('__cursor__');\n if (!cursorRow) {\n const head = await newestAuditId(base, readKey);\n await store.create({ key: '__cursor__', cursor: head, updated_at: iso() });\n return Response.json({ initialized: true, cursor: head });\n }\n let cursor = cursorRow.data.cursor ?? '0';\n const cursorVersion = cursorRow.version;\n\n // 2\u20134. drain + evaluate\n const posts: string[] = [];\n let scanned = 0;\n let matched = 0;\n for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {\n const { rows, next } = await exportAudit(base, readKey, cursor);\n for (const row of rows) {\n scanned++;\n const rule = RULES[row.event];\n if (rule) {\n matched++;\n const msg = await evaluate(store, rule, row);\n if (msg) posts.push(msg);\n }\n cursor = String(row.id);\n }\n if (!next) break;\n cursor = next;\n }\n\n // deliver (bounded)\n let posted = 0;\n const lines = posts.slice(0, MAX_POSTS_PER_RUN);\n if (posts.length > MAX_POSTS_PER_RUN) lines.push(`\u2026 and ${posts.length - MAX_POSTS_PER_RUN} more state changes this tick`);\n for (const text of lines) {\n const r = await post(hook, text);\n if (r.ok) posted++;\n }\n\n // 5. advance the watermark \u2014 If-Match: a concurrent run that already moved it wins\n const advanced = await store.patch(cursorRow.item_id, cursorVersion, { cursor, updated_at: iso() });\n\n return Response.json({ scanned, matched, alerts: posts.length, posted, cursor, advanced });\n },\n};\n\n/** Decide, for one matched audit row, whether a message is due; persist the state. */\nasync function evaluate(store: Store, rule: Rule, row: AuditRow): Promise<string | null> {\n const p = row.payload ?? {};\n const key = rule.key(p);\n const next: State = rule.state ?? (isState(p.state) ? p.state : 'broken');\n const level: Level = rule.level === 'payload' ? (isLevel(p.level) ? p.level : 'error') : rule.level;\n const title = rule.title(p);\n const detail = str(p.reason ?? p.error ?? p.message ?? p.last_error_msg, '').slice(0, DETAIL_MAX);\n const now = iso();\n const eventId = String(row.id);\n const base: StateData = { key, state: next, level, event: row.event, detail, last_event_id: eventId, updated_at: now };\n\n const current = await store.byKey(key);\n if (!current) {\n // First sighting. A green first sighting is remembered silently \u2014 nothing was red.\n if (next === 'ok') { await store.create(base); return null; }\n const created = await store.create({ ...base, last_alert_at: now });\n return created ? format(level, next, title, detail, row.created_at) : null; // 409 = a racing run owns it\n }\n const prev = current.data.state ?? 'ok';\n if (prev !== next) {\n // A CROSSING. If-Match: exactly one concurrent run wins the transition.\n const ok = await store.patch(current.item_id, current.version, { ...base, last_alert_at: now });\n if (!ok) return null;\n return next === 'ok'\n ? `\u{1F7E2} RESOLVED: ${title} is back to ok (was ${prev})`\n : format(level, next, title, detail, row.created_at);\n }\n // Same state. Red stays quiet \u2014 unless a reminder is due.\n if (next !== 'ok' && REPEAT_AFTER_HOURS > 0) {\n const last = Date.parse(current.data.last_alert_at ?? '') || 0;\n if (Date.now() - last >= REPEAT_AFTER_HOURS * 3600e3) {\n const ok = await store.patch(current.item_id, current.version, { ...base, last_alert_at: now });\n return ok ? `\u23F0 STILL ${next.toUpperCase()}: ${title} (since ${current.data.last_alert_at ?? '?'})` : null;\n }\n }\n await store.patch(current.item_id, current.version, base); // keep detail/last_event_id fresh, no post\n return null;\n}\n\nfunction format(level: Level, state: State, title: string, detail: string, at?: string): string {\n const dot = level === 'error' ? '\u{1F534}' : level === 'warn' ? '\u{1F7E0}' : '\u{1F535}';\n return `${dot} [${level.toUpperCase()}] ${title} \u2192 ${state.toUpperCase()}${detail ? ` \u2014 ${detail}` : ''}${at ? ` (at ${at})` : ''}`;\n}\n\n// \u2500\u2500 the audit stream (ascending NDJSON export; `x-vxil-next-after-id` continues it) \u2500\u2500\nasync function exportAudit(base: string, key: string, afterId: string): Promise<{ rows: AuditRow[]; next: string | null }> {\n const res = await fetch(`${base}/v1/audit/export?after_id=${encodeURIComponent(afterId)}&limit=500`, {\n headers: { authorization: `Bearer ${key}` },\n });\n if (!res.ok) throw new Error(`audit export ${res.status}`);\n const rows = (await res.text()).split('\\n').filter(Boolean).map((l) => JSON.parse(l) as AuditRow);\n return { rows, next: res.headers.get('x-vxil-next-after-id') };\n}\nasync function newestAuditId(base: string, key: string): Promise<string> {\n const res = await fetch(`${base}/v1/audit?limit=1`, { headers: { authorization: `Bearer ${key}` } });\n if (!res.ok) throw new Error(`audit list ${res.status}`);\n const body = (await res.json()) as { data?: { events?: AuditRow[] } };\n return String(body.data?.events?.[0]?.id ?? '0');\n}\n\n// \u2500\u2500 the cms state store (the REST envelope: { data: { items:[{item_id, version, data}] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() { return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' }; }\n async byKey(key: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${this.base}/v1/cms/items/alert_state?filter=${filter}&limit=1`, { headers: this.h() });\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 (unique key already claimed by a concurrent run) */\n async create(data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/alert_state`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ data, status: 'published' }),\n });\n return res.ok;\n }\n /** false on 409 version_conflict (another run transitioned this key first) */\n async patch(itemId: string, version: number | undefined, data: Partial<StateData>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/alert_state/${itemId}`, {\n method: 'PATCH',\n headers: { ...this.h(), ...(version !== undefined ? { 'if-match': String(version) } : {}) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n\n// \u2500\u2500 the chat webhook: `text` is Slack's field, `content` is Discord's \u2014 send both,\n// EXCEPT to a Google Chat space webhook (chat.googleapis.com), which takes\n// `{ text }` alone \u2014 an unknown field can be rejected there, so it is dropped.\nasync function post(url: string, text: string): Promise<{ ok: boolean; status: number }> {\n let host = '';\n try { host = new URL(url).hostname; } catch { /* unparseable \u2192 the generic body below */ }\n const body = host === 'chat.googleapis.com' ? { text } : { text, content: text };\n const res = await fetch(url, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n }).catch(() => null);\n return { ok: Boolean(res?.ok), status: res?.status ?? 0 };\n}\n\nconst iso = () => new Date().toISOString();\nconst isState = (v: unknown): v is State => v === 'ok' || v === 'stale' || v === 'broken' || v === 'could_not_check';\nconst isLevel = (v: unknown): v is Level => v === 'info' || v === 'warn' || v === 'error';\n"
18278
+ "alerts.ts": "// alerts.ts \u2014 FAILURE EVENTS \u2192 ONE CHAT MESSAGE PER STATE CROSSING (a vxil function).\n//\n// Trigger: cron `*/5 * * * *`. Each run:\n// 1. reads the watermark (the `__cursor__` row in `alert_state`; first run = \"now\",\n// so history never produces an alert storm),\n// 2. drains the tenant audit stream past it \u2014 `GET /v1/audit/export?after_id=\u2026`\n// (ascending, NDJSON) with the `vxil_read_key` secret (a key holding ONLY\n// `features:read`; the audit stream is not on the scoped callback),\n// 3. matches each row against RULES (the allow-list below) and turns it into a\n// (key, state, level) \u2014 e.g. `payments:ingest:stripe` \u2192 `broken`,\n// 4. compares with the stored state for that key (cms, one row per key, `unique`\n// key \u21D2 a racing run gets a 409 and stands down) and posts ONLY on a crossing:\n// ok\u2192red = alert, red\u2192ok = \"resolved\", red\u2192red = silence (or a reminder after\n// REPEAT_AFTER_HOURS),\n// 5. advances the watermark (If-Match on the cursor row's version \u2014 a concurrent\n// run that already advanced it wins; jobs may deliver a cron tick twice).\n//\n// Manual check: `vxil functions invoke alerts --data '{\"test\":true}'` posts a test\n// message and touches nothing else.\n//\n// Tune the constants; own the RULES table \u2014 it is data, not a routing engine.\n\n// cron-walk: persisted-cursor \u2014 the audit watermark lives in the `__cursor__` row and advances with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst REPEAT_AFTER_HOURS = 24; // remind about a still-red condition this often; 0 = never\nconst MAX_PAGES_PER_RUN = 5; // \xD7 500 audit rows \u2014 bounds one cron tick\nconst MAX_POSTS_PER_RUN = 20; // a burst of distinct failures collapses into one summary line\nconst DETAIL_MAX = 240;\n\ntype Level = 'info' | 'warn' | 'error';\ntype State = 'ok' | 'stale' | 'broken' | 'could_not_check';\ntype Payload = Record<string, unknown>;\n\ninterface Rule {\n /** A literal level, or 'payload' to take `payload.level` (the payments failure\n * events carry one; the jobs/functions ones do not). */\n level: Level | 'payload';\n key: (p: Payload) => string; // one state row per key \u2014 this is what suppresses repeats\n title: (p: Payload) => string;\n /** A literal state, or omitted to take `payload.state` (falling back to 'broken'). */\n state?: State;\n}\n\nconst str = (v: unknown, fallback = 'unknown') => (typeof v === 'string' && v ? v : fallback);\n\n/** THE ALLOW-LIST. Every name below is an audit event a vxil feature writes;\n * anything not listed is ignored by the drain. The KEY is what collapses\n * repeats: ten dead letters of the same job are one key, one row, one alert.\n *\n * Every name below is emitted today (2026-09-10). A rule for an event nobody\n * emits would simply never match \u2014 so if you add one, verify the emitter first.\n */\nconst RULES: Record<string, Rule> = {\n // \u2500\u2500 payments \u2014 the money path. A silent failure here is lost revenue. \u2500\u2500\u2500\u2500\u2500\u2500\n // These three carry `level` (info|warn|error) and `state` (ok|stale|broken|\n // could_not_check) in their payload, so the rule defers to the event.\n 'payments.webhook_event.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:ingest:${str(p.provider, 'all')}`,\n title: (p) => `payments ingest (${str(p.provider, 'all providers')})`,\n },\n 'payments.webhook.rejected': {\n level: 'payload', // emitted as warn/broken\n key: (p) => `payments:webhook-rejected:${str(p.provider)}`,\n title: (p) => `payments webhook rejected (${str(p.provider)})`,\n },\n // NOTE: grant.failed carries NO `provider` \u2014 it is keyed on the credit type\n // and the grant source instead (end_user_id/credit_type/amount/grant_key/\n // source/error). Keying on a field an event does not carry would collapse\n // every provider into one bucket named \"unknown\".\n 'payments.grant.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:grant:${str(p.credit_type, 'credits')}:${str(p.source, 'webhook')}`,\n title: (p) => `entitlement grant failed (${str(p.credit_type, 'credits')} via ${str(p.source, 'webhook')})`,\n },\n // Dunning. `past_due` is the red crossing and `active` is its RESOLVED twin \u2014\n // the same key, so a recovered subscription closes its own alert. Both are\n // emitted at level info, so the rule forces the level it wants.\n 'payments.subscription.past_due': {\n level: 'warn',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'broken',\n },\n 'payments.subscription.active': {\n level: 'info',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'ok',\n },\n // A dispute is a chargeback in progress \u2014 money already collected, now at risk.\n 'payments.charge.disputed': {\n level: 'warn',\n key: (p) => `payments:disputes:${str(p.provider)}`,\n title: (p) => `charge disputed (${str(p.provider)})`,\n state: 'broken',\n },\n\n // \u2500\u2500 jobs \u2014 the delivery substrate. Notification and outbound-webhook sends\n // ride jobs, so THEIR dead letters arrive here too, under `job_name`\n // ('notifications.deliver', 'webhooks.deliver', 'fn-cron:<name>', \u2026).\n // These payloads carry run_id/attempt/job_name and NO level or state, so\n // the rule supplies both. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 'job.dead_lettered': {\n level: 'error',\n key: (p) => `jobs:dead-letter:${str(p.job_name)}`,\n title: (p) => `job dead-lettered: ${str(p.job_name)}`,\n state: 'broken',\n },\n 'job.dead_letter_quota_exceeded': {\n level: 'error',\n key: () => 'jobs:dead-letter-quota',\n title: () => 'daily dead-letter quota exceeded',\n state: 'broken',\n },\n 'job.generation.failed': {\n level: 'error',\n key: (p) => `jobs:generation:${str(p.error_class, 'failed')}`,\n title: (p) => `generation job failed (${str(p.error_class, 'unclassified')})`,\n state: 'broken',\n },\n\n // \u2500\u2500 functions \u2014 the breaker. Consecutive failures quarantine a function;\n // clearing the quarantine is the ok crossing on the SAME key, so this pair\n // produces exactly one \u{1F534} and one \u{1F7E2}. Payload: name (+ consecutive_failures).\n 'functions.quarantined': {\n level: 'error',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'broken',\n },\n 'functions.quarantine.cleared': {\n level: 'info',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'ok',\n },\n 'functions.deploy.denied': {\n level: 'warn',\n key: () => 'functions:deploy-denied',\n title: (p) => `function deploy denied (needs the ${str(p.required_tier)} tier)`,\n state: 'broken',\n },\n\n // \u2500\u2500 The lifecycle failure events (live since 2026-09-10). Every one of these\n // carries `level` + `state` in its payload (the platform's failure-event\n // vocabulary gate enforces it), so the rules defer to the event. Each\n // failure/recovery pair shares a key, so a recovery posts RESOLVED. \u2500\u2500\u2500\u2500\u2500\u2500\n 'notifications.delivery.dead_lettered': {\n level: 'payload', // emitted as error/broken; payload: delivery_id, template_id, error_class, attempts\n key: (p) => `notifications:dead-letter:${str(p.template_id, 'all')}`,\n title: (p) => `notification delivery dead-lettered (${str(p.template_id, 'all templates')})`,\n },\n 'webhooks.delivery.dead_lettered': {\n level: 'payload', // error/broken; payload: subscription_id, target_host, attempts, error_class\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.delivery.recovered': {\n level: 'payload', // info/ok \u2014 same key as the dead-letter \u2192 RESOLVED\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.inbound.rejected': {\n level: 'payload', // warn/broken; payload: source_id, provider, reason, fingerprint (never the body)\n key: (p) => `webhooks:inbound:${str(p.source_id)}:${str(p.reason)}`,\n title: (p) => `inbound webhook rejected (${str(p.provider, 'source')} ${str(p.source_id)}: ${str(p.reason)})`,\n },\n 'jobs.schedule.missed': {\n level: 'payload', // warn/STALE \u2014 a late tick is stale, not broken; payload: schedule_id, job_name, late_seconds\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n 'jobs.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED on the next on-time fire\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n // A function's cron trigger that produced NO run for 2\xD7 its interval \u2014 the\n // platform's silent-schedule watch (error/stale), and the recovery on the same\n // key. Payload: function, schedule_id, cron, last_run_at, expected_by.\n 'functions.schedule.missed': {\n level: 'payload',\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))} (last run ${str(p.last_run_at, 'never')})`,\n },\n 'functions.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED when a run appears again\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))}`,\n },\n 'functions.run.failed': {\n level: 'payload', // error/broken; payload: name, trigger, run_id, error_class\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n 'functions.run.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n};\n\n// the schedule tick, or a hand invoke (`POST /v1/fn/alerts`) carrying `{ test: true }`\ntype Envelope = CronFunctionEnvelope | HttpFunctionEnvelope<{ test?: boolean }>;\ninterface AuditRow { id: string | number; event: string; payload?: Payload; created_at?: string }\ninterface StateData {\n key: string; state?: State; level?: Level; event?: string; detail?: string;\n cursor?: string; last_event_id?: string; last_alert_at?: string; updated_at?: string;\n}\ninterface Item { item_id: string; version?: number; data: StateData }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const hook = env.secrets?.slack_webhook_url;\n const readKey = env.secrets?.vxil_read_key;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n if (!hook || !readKey) return Response.json({ error: 'missing secrets' }, { status: 409 });\n\n // Manual wiring check \u2014 posts, touches nothing else.\n if (env.payload?.test) {\n const r = await post(hook, `\u{1F7E2} [INFO] alerts-to-slack is wired (test message)`);\n return Response.json({ test: true, posted: r.ok, status: r.status });\n }\n\n const store = new Store(base, cms);\n\n // 1. the watermark (first run: start at the newest audit id \u2014 no history storm)\n const cursorRow = await store.byKey('__cursor__');\n if (!cursorRow) {\n const head = await newestAuditId(base, readKey);\n await store.create({ key: '__cursor__', cursor: head, updated_at: iso() });\n return Response.json({ initialized: true, cursor: head });\n }\n let cursor = cursorRow.data.cursor ?? '0';\n const cursorVersion = cursorRow.version;\n\n // 2\u20134. drain + evaluate\n const posts: string[] = [];\n let scanned = 0;\n let matched = 0;\n for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {\n const { rows, next } = await exportAudit(base, readKey, cursor);\n for (const row of rows) {\n scanned++;\n const rule = RULES[row.event];\n if (rule) {\n matched++;\n const msg = await evaluate(store, rule, row);\n if (msg) posts.push(msg);\n }\n cursor = String(row.id);\n }\n if (!next) break;\n cursor = next;\n }\n\n // deliver (bounded)\n let posted = 0;\n const lines = posts.slice(0, MAX_POSTS_PER_RUN);\n if (posts.length > MAX_POSTS_PER_RUN) lines.push(`\u2026 and ${posts.length - MAX_POSTS_PER_RUN} more state changes this tick`);\n for (const text of lines) {\n const r = await post(hook, text);\n if (r.ok) posted++;\n }\n\n // 5. advance the watermark \u2014 If-Match: a concurrent run that already moved it wins\n const advanced = await store.patch(cursorRow.item_id, cursorVersion, { cursor, updated_at: iso() });\n\n return Response.json({ scanned, matched, alerts: posts.length, posted, cursor, advanced });\n },\n};\n\n/** Decide, for one matched audit row, whether a message is due; persist the state. */\nasync function evaluate(store: Store, rule: Rule, row: AuditRow): Promise<string | null> {\n const p = row.payload ?? {};\n const key = rule.key(p);\n const next: State = rule.state ?? (isState(p.state) ? p.state : 'broken');\n const level: Level = rule.level === 'payload' ? (isLevel(p.level) ? p.level : 'error') : rule.level;\n const title = rule.title(p);\n const detail = str(p.reason ?? p.error ?? p.message ?? p.last_error_msg, '').slice(0, DETAIL_MAX);\n const now = iso();\n const eventId = String(row.id);\n const base: StateData = { key, state: next, level, event: row.event, detail, last_event_id: eventId, updated_at: now };\n\n const current = await store.byKey(key);\n if (!current) {\n // First sighting. A green first sighting is remembered silently \u2014 nothing was red.\n if (next === 'ok') { await store.create(base); return null; }\n const created = await store.create({ ...base, last_alert_at: now });\n return created ? format(level, next, title, detail, row.created_at) : null; // 409 = a racing run owns it\n }\n const prev = current.data.state ?? 'ok';\n if (prev !== next) {\n // A CROSSING. If-Match: exactly one concurrent run wins the transition.\n const ok = await store.patch(current.item_id, current.version, { ...base, last_alert_at: now });\n if (!ok) return null;\n return next === 'ok'\n ? `\u{1F7E2} RESOLVED: ${title} is back to ok (was ${prev})`\n : format(level, next, title, detail, row.created_at);\n }\n // Same state. Red stays quiet \u2014 unless a reminder is due.\n if (next !== 'ok' && REPEAT_AFTER_HOURS > 0) {\n const last = Date.parse(current.data.last_alert_at ?? '') || 0;\n if (Date.now() - last >= REPEAT_AFTER_HOURS * 3600e3) {\n const ok = await store.patch(current.item_id, current.version, { ...base, last_alert_at: now });\n return ok ? `\u23F0 STILL ${next.toUpperCase()}: ${title} (since ${current.data.last_alert_at ?? '?'})` : null;\n }\n }\n await store.patch(current.item_id, current.version, base); // keep detail/last_event_id fresh, no post\n return null;\n}\n\nfunction format(level: Level, state: State, title: string, detail: string, at?: string): string {\n const dot = level === 'error' ? '\u{1F534}' : level === 'warn' ? '\u{1F7E0}' : '\u{1F535}';\n return `${dot} [${level.toUpperCase()}] ${title} \u2192 ${state.toUpperCase()}${detail ? ` \u2014 ${detail}` : ''}${at ? ` (at ${at})` : ''}`;\n}\n\n// \u2500\u2500 the audit stream (ascending NDJSON export; `x-vxil-next-after-id` continues it) \u2500\u2500\nasync function exportAudit(base: string, key: string, afterId: string): Promise<{ rows: AuditRow[]; next: string | null }> {\n const res = await fetch(`${base}/v1/audit/export?after_id=${encodeURIComponent(afterId)}&limit=500`, {\n headers: { authorization: `Bearer ${key}` },\n });\n if (!res.ok) throw new Error(`audit export ${res.status}`);\n const rows = (await res.text()).split('\\n').filter(Boolean).map((l) => JSON.parse(l) as AuditRow);\n return { rows, next: res.headers.get('x-vxil-next-after-id') };\n}\nasync function newestAuditId(base: string, key: string): Promise<string> {\n const res = await fetch(`${base}/v1/audit?limit=1`, { headers: { authorization: `Bearer ${key}` } });\n if (!res.ok) throw new Error(`audit list ${res.status}`);\n const body = (await res.json()) as { data?: { events?: AuditRow[] } };\n return String(body.data?.events?.[0]?.id ?? '0');\n}\n\n// \u2500\u2500 the cms state store (the REST envelope: { data: { items:[{item_id, version, data}] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() { return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' }; }\n async byKey(key: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${this.base}/v1/cms/items/alert_state?filter=${filter}&limit=1`, { headers: this.h() });\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 (unique key already claimed by a concurrent run) */\n async create(data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/alert_state`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ data, status: 'published' }),\n });\n return res.ok;\n }\n /** false on 409 version_conflict (another run transitioned this key first) */\n async patch(itemId: string, version: number | undefined, data: Partial<StateData>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/alert_state/${itemId}`, {\n method: 'PATCH',\n headers: { ...this.h(), ...(version !== undefined ? { 'if-match': String(version) } : {}) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n\n// \u2500\u2500 the chat webhook: `text` is Slack's field, `content` is Discord's \u2014 send both,\n// EXCEPT to a Google Chat space webhook (chat.googleapis.com), which takes\n// `{ text }` alone \u2014 an unknown field can be rejected there, so it is dropped.\nasync function post(url: string, text: string): Promise<{ ok: boolean; status: number }> {\n let host = '';\n try { host = new URL(url).hostname; } catch { /* unparseable \u2192 the generic body below */ }\n const body = host === 'chat.googleapis.com' ? { text } : { text, content: text };\n const res = await fetch(url, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n }).catch(() => null);\n return { ok: Boolean(res?.ok), status: res?.status ?? 0 };\n}\n\nconst iso = () => new Date().toISOString();\nconst isState = (v: unknown): v is State => v === 'ok' || v === 'stale' || v === 'broken' || v === 'could_not_check';\nconst isLevel = (v: unknown): v is Level => v === 'info' || v === 'warn' || v === 'error';\n"
17416
18279
  }
17417
18280
  },
17418
18281
  {
@@ -17435,7 +18298,7 @@ const json = (o: unknown, status: number) => Response.json(o, { status });
17435
18298
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// \"Payments heartbeat\" \u2014 THE SILENT-PROVIDER DETECTOR.\n//\n// Every other payments check answers \"did THIS event fold correctly?\". This one\n// answers the question nobody asks until it is too late: \"has my provider sent\n// me ANYTHING lately?\" A provider that stops posting produces no errors, no\n// failed webhooks, no red dashboard \u2014 just silence, and silence looks exactly\n// like a quiet week. This blueprint turns silence into a signal:\n// \u2022 payments \u2192 the webhook-event log (`GET /v1/payments/webhook-events`) is\n// already the record of every delivery your integration folded\n// \u2022 cms \u2192 `heartbeat_state`: one row per provider, so an alert fires on\n// the CROSSING and not once a day forever\n// \u2022 functions \u2192 `heartbeat`: a daily cron that measures days-since-last-\n// processed-event against YOUR OWN cadence and posts to Slack\n//\n// The threshold is not a number somebody picked: it is 3 \xD7 your median\n// inter-event gap, with a 7-day floor. A 100-subscriber app and a 100 000-\n// subscriber app have wildly different \"normal\", and a fixed number of days is\n// wrong for both.\n//\n// \u26A0 THIS IS AN OVERLAY, NOT A FRESH BACKEND. `vxil push` replaces a feature's\n// config wholesale. If you already run the payments feature, copy the\n// `heartbeat_state` collection and the `heartbeat` function into your EXISTING\n// vxil.config.ts rather than pushing this file \u2014 see README.md.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {},\n\n // Your payments INTEGRATION \u2014 your own provider account, your own keys;\n // vxil folds the lifecycle webhooks into a ledger and an entitlement\n // snapshot and is never in the flow of funds. Shown here so the file is a\n // coherent whole; if you already have a payments block, keep YOURS.\n payments: {\n provider: 'stripe',\n stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' },\n defaults: { currency: 'usd' },\n },\n\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n // The heartbeat's memory: ONE row per provider. `provider` is unique, so\n // two overlapping cron ticks cannot both create it (the loser gets 409).\n heartbeat_state: {\n singular: 'heartbeat_state',\n fields: {\n provider: { type: 'string', required: true, indexSlot: 's1', unique: true },\n state: { type: 'string', indexSlot: 's2' }, // ok | stale | broken | could_not_check\n detail: { type: 'text' }, // the human sentence that was (or would be) posted\n days_since: { type: 'float', indexSlot: 'n1' }, // days since the last processed event\n threshold_days: { type: 'float', indexSlot: 'n2' }, // max(7, 3 \xD7 median gap)\n last_event_at: { type: 'datetime', indexSlot: 't1' },\n checked_at: { type: 'datetime', indexSlot: 't2' },\n last_event_id: { type: 'string', indexSlot: 's3' },\n },\n },\n },\n },\n\n functions: {\n // Once a day is the right cadence for a check measured in DAYS. Also\n // invocable by hand \u2014 `vxil functions invoke heartbeat` \u2014 which runs the\n // real check and returns the per-provider verdict as JSON without posting\n // anything unless a state actually crossed.\n heartbeat: {\n entry: './functions/heartbeat.ts',\n trigger: { kind: 'cron', schedule: '0 7 * * *' },\n // payments:read = the webhook-event log; cms:* = the state rows. No write\n // scope on payments: a monitor must never be able to move money-adjacent\n // records, and this one structurally cannot.\n scopes: ['payments:read', 'cms:read', 'cms:write'],\n secrets: ['secret:slack_webhook_url'],\n egressAllow: ['hooks.slack.com'], // add 'discord.com' for Discord, 'chat.googleapis.com' for Google Chat\n },\n },\n\n secrets: {\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord / Google Chat space webhook) URL' },\n stripe_secret: { feature: 'payments', description: 'Stripe secret key (BYO \u2014 your own account)' },\n stripe_webhook: { feature: 'payments', description: 'Stripe webhook signing secret' },\n },\n});\n",
17436
18299
  "readme": "# Payments heartbeat (ops)\n\nEvery other payments check answers *\"did this event fold correctly?\"*. This one answers the question\nnobody asks until it is too late: **\"has my provider sent me anything lately?\"**\n\nA provider that stops posting produces no errors, no failed webhooks, no red dashboard \u2014 just silence,\nand silence looks exactly like a quiet week. One daily cron function measures **days since the last\nprocessed production event, per provider**, against **your own cadence**, keeps a three-state\n`ok | stale | broken` record in `cms`, and posts to Slack (or Discord, or Google Chat) **once per crossing**.\n\n```bash\nvxil init --template payments-heartbeat\nvxil quickstart # or `vxil link <slug>` for an existing backend\nprintf '%s' \"$SLACK_URL\" | vxil secrets set functions/slack_webhook_url\nvxil push # the heartbeat_state collection + the cron function\nvxil functions invoke heartbeat # run the real check now; prints the per-provider verdict\n```\n\n> \u26A0 **This blueprint is an overlay.** `vxil push` writes each feature's config as a whole version, so\n> pushing this file at a backend that already runs payments would replace your payments block with the\n> example one below. If you already have a `payments` integration configured, copy just the\n> **`heartbeat_state` collection** and the **`heartbeat` function** into your existing `vxil.config.ts`\n> and push that. On a fresh backend, push this file as-is and edit the payments block to your provider.\n\n## The threshold is derived, not guessed\n\nA fixed \"alert after 14 days\" is wrong for a 100-subscriber app *and* for a 100 000-subscriber one. So the\nfunction computes it from your own traffic:\n\n```\nthreshold_days = max( 7 , 3 \xD7 median gap between your last 100 processed events )\n```\n\n- **`3 \xD7` the median gap** \u2014 the median, not the mean, because one migration backfill or one Black Friday\n would inflate a mean for months. Three is the \"this is no longer a quiet week\" multiple.\n- **A 7-day floor** \u2014 a low-volume integration can legitimately go a week without a single lifecycle\n event, and alerting on that is how a channel gets muted.\n- **Fewer than 5 gaps sampled** \u2192 there is no meaningful cadence yet, so the floor is used on its own.\n- Past **2 \xD7** the threshold, `stale` becomes `broken`. That severity split is this blueprint's choice \u2014\n tune `BROKEN_MULTIPLE` in `functions/heartbeat.ts`; every constant at the top of that file is policy.\n\n`ALERT_MULTIPLE = 3` and `FLOOR_DAYS = 7` are the numbers the platform's own payments-ingest SLO uses for\nits silent-provider detection SLI, so the blueprint and the platform agree by construction.\n\n## What it reads\n\n```\nGET /v1/payments/webhook-events?provider=<p>&environment=production&outcome=processed&limit=100\n```\n\nwith the function's `payments:read` scope \u2014 the webhook event log your integration already writes for\nevery delivery it folds. Three details in that URL are the whole design:\n\n| Part | Why |\n|---|---|\n| `provider=<p>` | asked **per provider**, in a loop. A global query would be dominated by your chattiest provider, and a quiet one that went silent months ago would never appear in the newest page at all. |\n| `environment=production` | sandbox traffic is developer noise. A provider can be chatty in test mode while production has been silent for a month \u2014 exactly the outage this exists to catch. |\n| `outcome=processed` | not merely *received*. An event that arrived and never folded is a different failure (`payments.webhook_event.failed`, which `templates/alerts-to-slack/` picks up); this function is about arrival. |\n\nA provider that has **never** delivered a processed production event is skipped entirely \u2014 there is no\ncadence to be silent against. Edit `PROVIDERS` in the function to match the ones you actually use.\n\n## States and messages\n\n| State | When | Message |\n|---|---|---|\n| `ok` | `days_since \u2264 threshold` | (silent \u2014 or \u{1F7E2} RESOLVED if it was not ok before) |\n| `stale` | `threshold < days_since \u2264 2 \xD7 threshold` | \u{1F7E0} `[STALE] payments heartbeat \u2014 \u2026` |\n| `broken` | `days_since > 2 \xD7 threshold` | \u{1F534} `[BROKEN] payments heartbeat \u2014 \u2026` |\n| `could_not_check` | the event-log read itself failed (feature disabled, scope missing, network) | \u{1F535} `[CHECK FAILED] \u2026` \u2014 a monitor that cannot see is not a monitor that says \"fine\" |\n\n```\n\u{1F534} [BROKEN] payments heartbeat \u2014 stripe has sent no processed production event for 23.4d \u2014\n expected one within 7d (median gap 1.2d \xD7 3, floor 7d)\n\u{1F7E2} RESOLVED: stripe is delivering again \u2014 last processed event 0.1d ago (was broken)\n```\n\n## Once per crossing\n\nOne `heartbeat_state` row per provider, `provider` declared `unique`. Cron delivery is at-least-once, so\ntwo ticks can overlap: the unique field means only one can *create* the row (the other gets `409`), and\nevery update carries `If-Match: <version>`, so only one can *transition* it (the other gets `409\nversion_conflict` and stands down). A provider that stays broken for a month costs exactly **one**\nmessage, not thirty \u2014 and the row keeps `days_since` / `threshold_days` / `last_event_at` fresh the\nwhole time, so the dashboard always shows the current measurement even while the channel is quiet.\n\n## The scopes\n\n`payments:read`, `cms:read`, `cms:write` \u2014 and deliberately **no** payments write scope. A monitor should\nnot be able to touch money-adjacent records, and this one structurally cannot: the function's callback\ntoken is minted from exactly these scopes, so there is no write path to reach for.\n\nEgress is deny-by-default: `egressAllow: ['hooks.slack.com']`. Add `'discord.com'` for a Discord webhook \u2014\nthe message body carries both Slack's `text` and Discord's `content`, so one payload works on either.\nAdd `'chat.googleapis.com'` for a Google Chat space webhook \u2014 that host takes `text` alone, and the function\nsends only that field to it.\n\n## What to learn from this\n\n- **A monitor's threshold should come from the system it monitors.** Reading your own median gap turns\n one alert rule into a rule that fits every tenant.\n- **Absence is the hardest signal.** Nothing emits an event when a provider goes quiet, so the check has\n to be a *sweep*, not a subscription \u2014 which is what cron functions are for.\n- **`could_not_check` is a real state.** Folding \"I could not look\" into \"everything is fine\" is how\n monitoring dies silently; giving it its own state is one line and saves an outage.\n\n**Pairs with:** `templates/alerts-to-slack/` \u2014 the same suppression pattern applied to failure *events*\nrather than to silence, so together they cover both halves of the money path. The two ship the same\nSlack/Discord formatter on purpose: each blueprint is a self-contained clone, and every file under a\ntemplate's `functions/` directory must be a declared entry point, so there is nowhere for a shared module\nto live. Copy the file, own the copy.\n",
17437
18300
  "functions": {
17438
- "heartbeat.ts": "// heartbeat.ts \u2014 THE SILENT-PROVIDER DETECTOR (a vxil function, cron trigger).\n//\n// Trigger: cron `0 7 * * *`. For each provider you list in PROVIDERS:\n// 1. read the newest PROCESSED production webhook events \u2014\n// `GET /v1/payments/webhook-events?provider=<p>&environment=production\n// &outcome=processed&limit=100` (newest first),\n// 2. days_since = now \u2212 the newest event's received_at,\n// 3. threshold = max(FLOOR_DAYS, ALERT_MULTIPLE \xD7 the MEDIAN gap between\n// those events) \u2014 your own cadence, not a number somebody picked,\n// 4. ok | stale | broken (or could_not_check when the read itself failed),\n// 5. compare with the stored row for that provider and post to Slack ONLY on\n// a crossing; a return to ok posts RESOLVED.\n//\n// Why the median and not the mean: one migration backfill or one Black Friday\n// inflates a mean for months. The median is what \"normal\" actually looks like.\n//\n// Why `?environment=production`: sandbox traffic is developer noise. A provider\n// can be chatty in test mode while production has been silent for a month \u2014\n// that is precisely the outage this function exists to catch.\n//\n// Run it by hand any time: `vxil functions invoke heartbeat`. It performs the\n// real check and returns the per-provider verdict as JSON; it still only posts\n// if a state genuinely crossed.\n\n// \u2500\u2500 Tune these. They are the whole policy. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\n/** Providers to probe. Must be names the payments API accepts. Drop the ones\n * you do not use \u2014 a provider that has never sent an event is skipped anyway. */\nconst PROVIDERS = ['stripe', 'paddle', 'paypal', 'revenuecat'] as const;\n/** Alert once silence exceeds this multiple of your median inter-event gap. */\nconst ALERT_MULTIPLE = 3;\n/** \u2026but never sooner than this many days, however chatty your integration is.\n * A low-volume app can legitimately go a week without a single lifecycle event. */\nconst FLOOR_DAYS = 7;\n/** Past this multiple of the threshold, `stale` becomes `broken`. This split is\n * the blueprint's own choice \u2014 the SLO defines the alert threshold, not the\n * severity ladder \u2014 so move it wherever your escalation wants it. */\nconst BROKEN_MULTIPLE = 2;\n/** Gaps needed before a median means anything. Below this the floor is used. */\nconst MIN_GAPS_FOR_MEDIAN = 5;\n/** Events sampled per provider (the API caps a page at 100). */\nconst SAMPLE = 100;\n\ntype State = 'ok' | 'stale' | 'broken' | 'could_not_check';\n\ntype Envelope = CronFunctionEnvelope;\ninterface WebhookEvent { event_id: string; provider: string; received_at: string | null }\ninterface StateData {\n provider: string; state?: State; detail?: string;\n days_since?: number; threshold_days?: number;\n last_event_at?: string; checked_at?: string; last_event_id?: string;\n}\ninterface Item { item_id: string; version?: number; data: StateData }\n\ninterface Verdict {\n provider: string;\n state: State;\n detail: string;\n days_since: number | null;\n threshold_days: number | null;\n last_event_at: string | null;\n last_event_id: string | null;\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const payments = env.scoped_jwts?.payments;\n const cms = env.scoped_jwts?.cms;\n const hook = env.secrets?.slack_webhook_url;\n if (!payments || !cms) return Response.json({ error: 'missing payments:read / cms scope' }, { status: 403 });\n if (!hook) return Response.json({ error: 'missing secret slack_webhook_url' }, { status: 409 });\n\n const store = new Store(base, cms);\n const checked: Verdict[] = [];\n const crossings: string[] = [];\n\n for (const provider of PROVIDERS) {\n const verdict = await check(base, payments, provider);\n if (!verdict) continue; // never sent an event \u2192 not part of this integration\n checked.push(verdict);\n const line = await reconcile(store, verdict);\n if (line) crossings.push(line);\n }\n\n let posted = 0;\n for (const text of crossings) {\n const r = await post(hook, text);\n if (r.ok) posted++;\n }\n return Response.json({ checked, crossings: crossings.length, posted });\n },\n};\n\n/** Measure one provider. `null` = the provider has never delivered a processed\n * production event, so there is no cadence to be silent against \u2014 the SLO's\n * \"once the tenant has ever received one\" precondition. */\nasync function check(base: string, jwt: string, provider: string): Promise<Verdict | null> {\n const url = `${base}/v1/payments/webhook-events`\n + `?provider=${encodeURIComponent(provider)}&environment=production&outcome=processed&limit=${SAMPLE}`;\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } }).catch(() => null);\n if (!res) {\n return verdict(provider, 'could_not_check', `could not reach the payments event log for ${provider}`);\n }\n if (!res.ok) {\n // 501 capability_not_enabled / 403 missing scope are configuration, not silence.\n return verdict(provider, 'could_not_check', `payments event log returned ${res.status} for ${provider}`);\n }\n const body = (await res.json().catch(() => ({}))) as { data?: { events?: WebhookEvent[] } };\n const events = (body.data?.events ?? []).filter((e) => e.received_at);\n if (events.length === 0) return null;\n\n // The API orders by received_at DESC, so [0] is the newest.\n const times = events.map((e) => Date.parse(e.received_at!)).filter(Number.isFinite).sort((a, b) => b - a);\n if (times.length === 0) return null;\n const last = times[0]!;\n const daysSince = round2((Date.now() - last) / 86_400_000);\n\n const gaps: number[] = [];\n for (let i = 0; i + 1 < times.length; i++) gaps.push((times[i]! - times[i + 1]!) / 86_400_000);\n const medianGap = gaps.length >= MIN_GAPS_FOR_MEDIAN ? median(gaps) : null;\n const threshold = round2(Math.max(FLOOR_DAYS, medianGap === null ? 0 : ALERT_MULTIPLE * medianGap));\n\n const state: State = daysSince <= threshold ? 'ok'\n : daysSince <= threshold * BROKEN_MULTIPLE ? 'stale'\n : 'broken';\n\n const cadence = medianGap === null\n ? `only ${gaps.length} gap(s) sampled \u2014 using the ${FLOOR_DAYS}-day floor`\n : `median gap ${round2(medianGap)}d \xD7 ${ALERT_MULTIPLE}, floor ${FLOOR_DAYS}d`;\n const detail = state === 'ok'\n ? `${provider}: last processed event ${daysSince}d ago (threshold ${threshold}d \u2014 ${cadence})`\n : `${provider} has sent no processed production event for ${daysSince}d \u2014 expected one within ${threshold}d (${cadence})`;\n\n return {\n provider, state, detail,\n days_since: daysSince,\n threshold_days: threshold,\n last_event_at: new Date(last).toISOString(),\n last_event_id: events[0]!.event_id ?? null,\n };\n}\n\n/** Persist the verdict; return a message ONLY when the state crossed. */\nasync function reconcile(store: Store, v: Verdict): Promise<string | null> {\n const now = new Date().toISOString();\n const data: StateData = {\n provider: v.provider, state: v.state, detail: v.detail, checked_at: now,\n ...(v.days_since !== null ? { days_since: v.days_since } : {}),\n ...(v.threshold_days !== null ? { threshold_days: v.threshold_days } : {}),\n ...(v.last_event_at ? { last_event_at: v.last_event_at } : {}),\n ...(v.last_event_id ? { last_event_id: v.last_event_id } : {}),\n };\n\n const current = await store.byProvider(v.provider);\n if (!current) {\n // First run. A healthy first sighting is remembered silently; an unhealthy\n // one is worth saying out loud immediately \u2014 you were already in the outage.\n const created = await store.create(data);\n return created && v.state !== 'ok' ? format(v) : null; // 409 \u21D2 a racing tick owns it\n }\n\n const prev = current.data.state ?? 'ok';\n if (prev === v.state) {\n // No crossing: refresh the measurement, stay quiet. A permanently-broken\n // provider therefore costs exactly one message, not one per day.\n await store.patch(current.item_id, current.version, data);\n return null;\n }\n // A CROSSING. If-Match makes exactly one of two overlapping ticks the winner.\n const ok = await store.patch(current.item_id, current.version, data);\n if (!ok) return null;\n return v.state === 'ok'\n ? `\u{1F7E2} RESOLVED: ${v.provider} is delivering again \u2014 last processed event ${v.days_since}d ago (was ${prev})`\n : format(v);\n}\n\nfunction format(v: Verdict): string {\n const dot = v.state === 'broken' ? '\u{1F534}' : v.state === 'stale' ? '\u{1F7E0}' : '\u{1F535}';\n const label = v.state === 'could_not_check' ? 'CHECK FAILED' : v.state.toUpperCase();\n return `${dot} [${label}] payments heartbeat \u2014 ${v.detail}`;\n}\n\n// \u2500\u2500 the cms state store (the REST envelope: { data: { items: [{item_id, version, data}] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() { return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' }; }\n async byProvider(provider: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ provider }));\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state?filter=${filter}&limit=1`, { headers: this.h() });\n if (!res.ok) return null;\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 \u2014 `provider` is unique, so a concurrent tick already claimed it. */\n async create(data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ status: 'published', data }),\n });\n return res.ok;\n }\n /** false on 409 version_conflict \u2014 another tick transitioned this provider first. */\n async patch(itemId: string, version: number | undefined, data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state/${itemId}`, {\n method: 'PATCH',\n headers: { ...this.h(), ...(version !== undefined ? { 'if-match': String(version) } : {}) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n\n// \u2500\u2500 the chat webhook: `text` is Slack's field, `content` is Discord's \u2014 send both,\n// EXCEPT to a Google Chat space webhook (chat.googleapis.com), which takes\n// `{ text }` alone \u2014 an unknown field can be rejected there, so it is dropped.\n// (Duplicated from templates/alerts-to-slack on purpose: a blueprint is a\n// self-contained clone, and every file under functions/ must be a declared\n// entry point, so there is no place for a shared module to live.)\nasync function post(url: string, text: string): Promise<{ ok: boolean; status: number }> {\n let host = '';\n try { host = new URL(url).hostname; } catch { /* unparseable \u2192 the generic body below */ }\n const body = host === 'chat.googleapis.com' ? { text } : { text, content: text };\n const res = await fetch(url, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n }).catch(() => null);\n return { ok: Boolean(res?.ok), status: res?.status ?? 0 };\n}\n\nfunction verdict(provider: string, state: State, detail: string): Verdict {\n return { provider, state, detail, days_since: null, threshold_days: null, last_event_at: null, last_event_id: null };\n}\nfunction median(xs: number[]): number {\n const s = [...xs].sort((a, b) => a - b);\n const mid = s.length >> 1;\n return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2;\n}\nconst round2 = (n: number) => Math.round(n * 100) / 100;\n"
18301
+ "heartbeat.ts": "// heartbeat.ts \u2014 THE SILENT-PROVIDER DETECTOR (a vxil function, cron trigger).\n//\n// Trigger: cron `0 7 * * *`. For each provider you list in PROVIDERS:\n// 1. read the newest PROCESSED production webhook events \u2014\n// `GET /v1/payments/webhook-events?provider=<p>&environment=production\n// &outcome=processed&limit=100` (newest first),\n// 2. days_since = now \u2212 the newest event's received_at,\n// 3. threshold = max(FLOOR_DAYS, ALERT_MULTIPLE \xD7 the MEDIAN gap between\n// those events) \u2014 your own cadence, not a number somebody picked,\n// 4. ok | stale | broken (or could_not_check when the read itself failed),\n// 5. compare with the stored row for that provider and post to Slack ONLY on\n// a crossing; a return to ok posts RESOLVED.\n//\n// Why the median and not the mean: one migration backfill or one Black Friday\n// inflates a mean for months. The median is what \"normal\" actually looks like.\n//\n// Why `?environment=production`: sandbox traffic is developer noise. A provider\n// can be chatty in test mode while production has been silent for a month \u2014\n// that is precisely the outage this function exists to catch.\n//\n// Run it by hand any time: `vxil functions invoke heartbeat`. It performs the\n// real check and returns the per-provider verdict as JSON; it still only posts\n// if a state genuinely crossed.\n\n// \u2500\u2500 Tune these. They are the whole policy. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\n// cron-walk: single-read \u2014 a sample of the newest 100 events per provider is the measurement itself.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\n/** Providers to probe. Must be names the payments API accepts. Drop the ones\n * you do not use \u2014 a provider that has never sent an event is skipped anyway. */\nconst PROVIDERS = ['stripe', 'paddle', 'paypal', 'revenuecat'] as const;\n/** Alert once silence exceeds this multiple of your median inter-event gap. */\nconst ALERT_MULTIPLE = 3;\n/** \u2026but never sooner than this many days, however chatty your integration is.\n * A low-volume app can legitimately go a week without a single lifecycle event. */\nconst FLOOR_DAYS = 7;\n/** Past this multiple of the threshold, `stale` becomes `broken`. This split is\n * the blueprint's own choice \u2014 the SLO defines the alert threshold, not the\n * severity ladder \u2014 so move it wherever your escalation wants it. */\nconst BROKEN_MULTIPLE = 2;\n/** Gaps needed before a median means anything. Below this the floor is used. */\nconst MIN_GAPS_FOR_MEDIAN = 5;\n/** Events sampled per provider (the API caps a page at 100). */\nconst SAMPLE = 100;\n\ntype State = 'ok' | 'stale' | 'broken' | 'could_not_check';\n\ntype Envelope = CronFunctionEnvelope;\ninterface WebhookEvent { event_id: string; provider: string; received_at: string | null }\ninterface StateData {\n provider: string; state?: State; detail?: string;\n days_since?: number; threshold_days?: number;\n last_event_at?: string; checked_at?: string; last_event_id?: string;\n}\ninterface Item { item_id: string; version?: number; data: StateData }\n\ninterface Verdict {\n provider: string;\n state: State;\n detail: string;\n days_since: number | null;\n threshold_days: number | null;\n last_event_at: string | null;\n last_event_id: string | null;\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const payments = env.scoped_jwts?.payments;\n const cms = env.scoped_jwts?.cms;\n const hook = env.secrets?.slack_webhook_url;\n if (!payments || !cms) return Response.json({ error: 'missing payments:read / cms scope' }, { status: 403 });\n if (!hook) return Response.json({ error: 'missing secret slack_webhook_url' }, { status: 409 });\n\n const store = new Store(base, cms);\n const checked: Verdict[] = [];\n const crossings: string[] = [];\n\n for (const provider of PROVIDERS) {\n const verdict = await check(base, payments, provider);\n if (!verdict) continue; // never sent an event \u2192 not part of this integration\n checked.push(verdict);\n const line = await reconcile(store, verdict);\n if (line) crossings.push(line);\n }\n\n let posted = 0;\n for (const text of crossings) {\n const r = await post(hook, text);\n if (r.ok) posted++;\n }\n return Response.json({ checked, crossings: crossings.length, posted });\n },\n};\n\n/** Measure one provider. `null` = the provider has never delivered a processed\n * production event, so there is no cadence to be silent against \u2014 the SLO's\n * \"once the tenant has ever received one\" precondition. */\nasync function check(base: string, jwt: string, provider: string): Promise<Verdict | null> {\n const url = `${base}/v1/payments/webhook-events`\n + `?provider=${encodeURIComponent(provider)}&environment=production&outcome=processed&limit=${SAMPLE}`;\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } }).catch(() => null);\n if (!res) {\n return verdict(provider, 'could_not_check', `could not reach the payments event log for ${provider}`);\n }\n if (!res.ok) {\n // 501 capability_not_enabled / 403 missing scope are configuration, not silence.\n return verdict(provider, 'could_not_check', `payments event log returned ${res.status} for ${provider}`);\n }\n const body = (await res.json().catch(() => ({}))) as { data?: { events?: WebhookEvent[] } };\n const events = (body.data?.events ?? []).filter((e) => e.received_at);\n if (events.length === 0) return null;\n\n // The API orders by received_at DESC, so [0] is the newest.\n const times = events.map((e) => Date.parse(e.received_at!)).filter(Number.isFinite).sort((a, b) => b - a);\n if (times.length === 0) return null;\n const last = times[0]!;\n const daysSince = round2((Date.now() - last) / 86_400_000);\n\n const gaps: number[] = [];\n for (let i = 0; i + 1 < times.length; i++) gaps.push((times[i]! - times[i + 1]!) / 86_400_000);\n const medianGap = gaps.length >= MIN_GAPS_FOR_MEDIAN ? median(gaps) : null;\n const threshold = round2(Math.max(FLOOR_DAYS, medianGap === null ? 0 : ALERT_MULTIPLE * medianGap));\n\n const state: State = daysSince <= threshold ? 'ok'\n : daysSince <= threshold * BROKEN_MULTIPLE ? 'stale'\n : 'broken';\n\n const cadence = medianGap === null\n ? `only ${gaps.length} gap(s) sampled \u2014 using the ${FLOOR_DAYS}-day floor`\n : `median gap ${round2(medianGap)}d \xD7 ${ALERT_MULTIPLE}, floor ${FLOOR_DAYS}d`;\n const detail = state === 'ok'\n ? `${provider}: last processed event ${daysSince}d ago (threshold ${threshold}d \u2014 ${cadence})`\n : `${provider} has sent no processed production event for ${daysSince}d \u2014 expected one within ${threshold}d (${cadence})`;\n\n return {\n provider, state, detail,\n days_since: daysSince,\n threshold_days: threshold,\n last_event_at: new Date(last).toISOString(),\n last_event_id: events[0]!.event_id ?? null,\n };\n}\n\n/** Persist the verdict; return a message ONLY when the state crossed. */\nasync function reconcile(store: Store, v: Verdict): Promise<string | null> {\n const now = new Date().toISOString();\n const data: StateData = {\n provider: v.provider, state: v.state, detail: v.detail, checked_at: now,\n ...(v.days_since !== null ? { days_since: v.days_since } : {}),\n ...(v.threshold_days !== null ? { threshold_days: v.threshold_days } : {}),\n ...(v.last_event_at ? { last_event_at: v.last_event_at } : {}),\n ...(v.last_event_id ? { last_event_id: v.last_event_id } : {}),\n };\n\n const current = await store.byProvider(v.provider);\n if (!current) {\n // First run. A healthy first sighting is remembered silently; an unhealthy\n // one is worth saying out loud immediately \u2014 you were already in the outage.\n const created = await store.create(data);\n return created && v.state !== 'ok' ? format(v) : null; // 409 \u21D2 a racing tick owns it\n }\n\n const prev = current.data.state ?? 'ok';\n if (prev === v.state) {\n // No crossing: refresh the measurement, stay quiet. A permanently-broken\n // provider therefore costs exactly one message, not one per day.\n await store.patch(current.item_id, current.version, data);\n return null;\n }\n // A CROSSING. If-Match makes exactly one of two overlapping ticks the winner.\n const ok = await store.patch(current.item_id, current.version, data);\n if (!ok) return null;\n return v.state === 'ok'\n ? `\u{1F7E2} RESOLVED: ${v.provider} is delivering again \u2014 last processed event ${v.days_since}d ago (was ${prev})`\n : format(v);\n}\n\nfunction format(v: Verdict): string {\n const dot = v.state === 'broken' ? '\u{1F534}' : v.state === 'stale' ? '\u{1F7E0}' : '\u{1F535}';\n const label = v.state === 'could_not_check' ? 'CHECK FAILED' : v.state.toUpperCase();\n return `${dot} [${label}] payments heartbeat \u2014 ${v.detail}`;\n}\n\n// \u2500\u2500 the cms state store (the REST envelope: { data: { items: [{item_id, version, data}] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() { return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' }; }\n async byProvider(provider: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ provider }));\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state?filter=${filter}&limit=1`, { headers: this.h() });\n if (!res.ok) return null;\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 \u2014 `provider` is unique, so a concurrent tick already claimed it. */\n async create(data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ status: 'published', data }),\n });\n return res.ok;\n }\n /** false on 409 version_conflict \u2014 another tick transitioned this provider first. */\n async patch(itemId: string, version: number | undefined, data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state/${itemId}`, {\n method: 'PATCH',\n headers: { ...this.h(), ...(version !== undefined ? { 'if-match': String(version) } : {}) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n\n// \u2500\u2500 the chat webhook: `text` is Slack's field, `content` is Discord's \u2014 send both,\n// EXCEPT to a Google Chat space webhook (chat.googleapis.com), which takes\n// `{ text }` alone \u2014 an unknown field can be rejected there, so it is dropped.\n// (Duplicated from templates/alerts-to-slack on purpose: a blueprint is a\n// self-contained clone, and every file under functions/ must be a declared\n// entry point, so there is no place for a shared module to live.)\nasync function post(url: string, text: string): Promise<{ ok: boolean; status: number }> {\n let host = '';\n try { host = new URL(url).hostname; } catch { /* unparseable \u2192 the generic body below */ }\n const body = host === 'chat.googleapis.com' ? { text } : { text, content: text };\n const res = await fetch(url, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n }).catch(() => null);\n return { ok: Boolean(res?.ok), status: res?.status ?? 0 };\n}\n\nfunction verdict(provider: string, state: State, detail: string): Verdict {\n return { provider, state, detail, days_since: null, threshold_days: null, last_event_at: null, last_event_id: null };\n}\nfunction median(xs: number[]): number {\n const s = [...xs].sort((a, b) => a - b);\n const mid = s.length >> 1;\n return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2;\n}\nconst round2 = (n: number) => Math.round(n * 100) / 100;\n"
17439
18302
  }
17440
18303
  },
17441
18304
  {
@@ -17460,7 +18323,7 @@ const json = (o: unknown, status: number) => Response.json(o, { status });
17460
18323
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// \"Job Runner\" \u2014 the BACKGROUND-WORK blueprint. Four ways work leaves the\n// request path, and what happens when it fails:\n//\n// \u2022 enqueue \u2192 one-off work, deduplicated by an idempotency key, and\n// optionally deferred (seconds from now, or an exact time)\n// \u2022 schedules \u2192 cron, with a LATE schedule reporting itself\n// \u2022 queue trigger \u2192 a deployed function that IS the worker\n// \u2022 generation \u2192 a long provider call vxil babysits for you, holding\n// credits while it runs and releasing them when it ends\n// \u2022 the spine \u2192 every failure is an audit event; one cron function turns\n// the ones that matter into `incidents` rows\n//\n// The credits half is a payments INTEGRATION with the deterministic `mock`\n// provider, so the whole reserve \u2192 settle \u2192 refund story runs with no provider\n// account at all. \"Credits\" here are usage units, not money.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n // a failing delivery is retried this many times before it dead-letters\n retry: { defaultMaxAttempts: 3 },\n retention: { successfulRunDays: 7, failedRunDays: 30 },\n // once this many runs have dead-lettered today, a further failing run\n // skips its remaining retries and dead-letters immediately \u2014 a bound on\n // the churn ONE pathological target can generate. 0 = unlimited.\n dlqDailyQuota: 200,\n concurrency: { maxConcurrent: 5 },\n schedules: { maxPerTenant: 20 },\n generation: {\n maxConcurrent: 5,\n defaultTimeoutMs: 300_000, // 5 min unless the descriptor says otherwise\n pollMaxAttempts: 30, // give up after 30 polls \u2192 terminal fail\n maxReserveCredits: 200, // a single run can never hold more than this\n maxOutstandingReserveCredits: 5_000, // \u2026nor can all in-flight runs together\n },\n },\n\n // The ledger half. `mock` is the deterministic default provider: no keys,\n // no account, the entire credits path exercisable end to end. Point it at\n // your own Stripe/Paddle/PayPal/RevenueCat account when you go live \u2014 vxil\n // is never in the flow of funds.\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n // product id \u2192 what buying it grants. Consumed by the provider webhook\n // reducer; the map is DATA, so adding products never changes the config.\n productMap: {\n render_pack_1000: { creditType: 'render_credits', amount: 1000, period: 'once' },\n },\n // tier \u2192 what being on it entitles you to, and what it tops up monthly\n tierMap: {\n pro: {\n entitlements: ['render'],\n quotas: { renders_per_day: 500 },\n rank: 10,\n grants: [{ creditType: 'render_credits', amount: 5_000, period: 'monthly' }],\n },\n },\n // a generation that ends in failure gives its held credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {},\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n // The work items. One row per requested render.\n renders: {\n singular: 'render',\n fields: {\n // THE DEDUPE ANCHOR. Queue deliveries are at-least-once, so the\n // worker may see the same item twice; a unique field turns the second\n // write into a clean 409 the function treats as \"already done\".\n request_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n title: { type: 'string', indexSlot: 's2' },\n state: { type: 'string', indexSlot: 's3' }, // queued | processing | done | failed\n run_id: { type: 'string', indexSlot: 's4' }, // the jobs run that owns it\n credits: { type: 'int', indexSlot: 'n1' }, // held for this render\n created_at: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n\n // The failure memory. One row per incident KEY, so ten dead letters of\n // the same job are one row \u2014 plus the reserved `__cursor__` row holding\n // the audit-stream watermark.\n incidents: {\n singular: 'incident',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n event: { type: 'string', indexSlot: 's2' }, // the audit event that raised it\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n subject: { type: 'string', indexSlot: 's4' }, // job name / schedule id\n seen_count: { type: 'int', indexSlot: 'n1' },\n first_seen: { type: 'datetime', indexSlot: 't1' },\n last_seen: { type: 'datetime', indexSlot: 't2' },\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n detail: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // THE WORKER. A queue-triggered function is invoked by an enqueue, not by a\n // request \u2014 it has no URL a browser can reach. Delivery is at-least-once,\n // so it dedupes on `request_key` rather than assuming exactly-once.\n 'process-batch': {\n entry: './functions/process-batch.ts',\n trigger: { kind: 'queue', source: 'renders' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // THE FAILURE DRAIN. Every feature writes its lifecycle to your tenant's\n // audit stream; this reads the stream past a watermark every 5 minutes and\n // turns the failure events that matter into `incidents` rows.\n 'incident-watch': {\n entry: './functions/incident-watch.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' },\n scopes: ['cms:read', 'cms:write'],\n // The audit stream is a control-plane read, not one of the feature APIs\n // the function's scoped callback covers \u2014 so the drain reads it with the\n // narrowest key that can: one holding ONLY `features:read`.\n secrets: ['secret:vxil_read_key'],\n egressAllow: [],\n },\n },\n\n secrets: {\n vxil_read_key: {\n feature: 'functions',\n description: 'a vxil API key of this backend holding ONLY features:read \u2014 reads the audit stream',\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'renders',\n items: [\n {\n request_key: 'seed-0001',\n title: 'Quarterly report render',\n state: 'queued',\n credits: 10,\n created_at: '2026-04-01T08:00:00Z',\n notes: 'Seed row so the collection is not empty on first push.',\n },\n ],\n },\n ],\n },\n});\n",
17461
18324
  "readme": '# Job Runner (ops)\n\nFour ways work leaves the request path, and one answer for what happens when it fails. If you have\never written a `jobs` table, a worker loop, a retry counter and a "why did this run twice?" post\nmortem, this is that, declared.\n\n```bash\nvxil init --template job-runner\nvxil quickstart # or `vxil link <slug>`\nprintf \'%s\' "$READ_KEY" | vxil secrets set functions/vxil_read_key\nvxil push # collections + both functions\n```\n\n`vxil_read_key` is an API key **of this same backend** holding **only `features:read`** \u2014 the\nincident drain reads the audit stream with it (dashboard \u2192 API keys \u2192 create, tick `features:read`\nand nothing else). Everything else here needs no credentials at all: the payments integration runs\non the deterministic `mock` provider, so the whole credits story works before you have a provider\naccount. "Credits" are usage units you meter, not money and not stored value.\n\n## The four ways work leaves the request path\n\n| | You call | It runs | Use it when |\n|---|---|---|---|\n| **Enqueue** | `POST /v1/jobs/enqueue` | now, or later | one-off work, deduplicated by a key you choose |\n| **Schedule** | `POST /v1/jobs/schedules` | on a 5-field UTC cron | recurring work \u2014 and it tells you when it ran late |\n| **Queue trigger** | `vxil functions invoke <fn> --async` | a deployed function | the worker IS your code, with no URL to expose |\n| **Generation** | `POST /v1/jobs/generation` | a long provider call vxil babysits | a model/render/export that answers in minutes, not milliseconds |\n\nAnd one answer for failure: **every feature writes its lifecycle to your audit stream**, and the\n`incident-watch` cron turns the failures that matter into rows you can query.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `jobs:read jobs:write payments:read payments:write cms:read cms:write\nfunctions:read functions:invoke features:read`.\n\n**1. Enqueue the same thing twice.** The dedupe scope is `(job_name, idempotency_key)`:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deduplicated": true } } \u2190 the SAME run_id\n```\n\nNote where the key goes: **in the body**, as `idempotency_key`. (The `Idempotency-Key` *header* is\nwhat the notifications and payments surfaces read \u2014 jobs reads the field.) The window is 24 hours,\nand it is backstopped in the database, so two racing enqueues cannot both win.\n\n**2. Run it later.** Two mutually exclusive fields \u2014 pick one:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","delay_seconds":900}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deliver_after": "\u2026T12:26:12.686Z" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","deliver_after":"2026-12-24T09:00:00Z"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "delayed", "deliver_after": "2026-12-24T09:00:00.000Z" } }\n```\n\n`state` tells you which machinery is holding it: a short delay rides the queue itself and fires on\nthe second; anything beyond twelve hours is parked as `delayed` and released by a minute-tick\nsweep. The ceiling is 30 days. A `deliver_after` in the past is a 422, not a surprise.\n\n**3. A schedule that reports itself late.**\n\n```bash\nvxil api POST /v1/jobs/schedules --data \'{"job_name":"nightly.rollup","target_url":"https://hooks.example.com/rollup","cron":"0 2 * * *"}\'\n# 201 { "data": { "schedule_id": "sch_\u2026", "next_run_at": "2026-09-12T02:00:00.000Z", "state": "active" } }\n```\n\nFive UTC fields, with `*`, numbers, lists, ranges and steps \u2014 no month or weekday names, no `L`/`W`.\nWhen a tick finally fires more than **twice its own interval** late, the platform writes\n`jobs.schedule.missed` (once per crossing, not once per tick) and `jobs.schedule.recovered` when it\ncatches up. Step 7 turns both into rows.\n\n**4. The worker is your function.** `process-batch` is queue-triggered \u2014 it has no URL a browser can\nreach. Hand it a batch:\n\n```bash\nvxil functions invoke process-batch --async --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api GET "/v1/cms/items/renders?filter=%7B%22state%22%3A%22queued%22%7D"\n# 200 \u2026 two new rows, request_key "b-1:a" and "b-1:b"\n```\n\n(`--async` enqueues it; drop the flag to call the same function synchronously and\nread its answer. `--async` needs a linked project \u2014 `vxil quickstart` or `vxil link <slug>`.)\n\nNow send the **exact same batch again**, synchronously so you can read the verdict:\n\n```bash\nvxil functions invoke process-batch --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# { "batch_id": "b-1", "created": 0, "duplicates": 2, "rejected": 0 }\n```\n\nNothing was duplicated, and the function contains no dedupe logic. `request_key` is declared\n`unique`, so the second create is a 409 \u2014 and the function reads a 409 as *already done*. That is\nthe whole strategy: **delivery is at-least-once, so correctness lives in a declared field, not in a\nhope that it runs once.**\n\n**5. Credits: reserve \u2192 settle.** Grant some usage units first (the header is required here \u2014 this\nis the money-shaped surface):\n\n```bash\ncurl -s -X POST "https://api.vxil.com/v1/payments/credits/grant" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -H \'idempotency-key: seed-1\' \\\n -d \'{"user_id":"u_demo","credit_type":"render_credits","amount":100,"source":"seed"}\'\n# 200 { "data": { "balance_after": 100, "ledger_entry_id": null } }\n\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "credit_type": "render_credits", "balance": 100, "held": 0, "available": 100, \u2026 } }\n```\n\nNow start a generation that holds ten of them while it runs:\n\n```bash\nvxil api POST /v1/jobs/generation --data \'{\n "job_name": "render.deck",\n "provider": { "url": "https://api.your-render-provider.example/v1/renders",\n "method": "POST",\n "body": { "format": "pdf" } },\n "completion": { "mode": "poll",\n "status_path": "status",\n "poll": { "url": "https://api.your-render-provider.example/v1/renders/latest",\n "method": "GET", "interval_ms": 5000 } },\n "reserve_credits": { "amount": 10, "user_id": "u_demo",\n "credit_type": "render_credits", "reason": "deck render" },\n "timeout": { "after_ms": 120000 },\n "payload": { "render_id": "b-1:a" }\n}\'\n# 202 { "data": { "run_id": "run_\u2026", "generation_status": "pending", "state": "queued" } }\n```\n\nImmediately, the balance moves \u2014 but only the **held** half:\n\n```bash\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "balance": 100, "held": 10, "available": 90, \u2026 } }\n\nvxil api GET "/v1/payments/usage?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "user_id": "u_demo", "entries": [\n# { "kind": "consume", "state": "provisional", "delta": -10, "job_id": "run_\u2026", "source": "deck render", \u2026 },\n# { "kind": "grant", "state": "committed", "delta": 100, \u2026 } ] } }\n```\n\n`state: "provisional"` is the reservation. Nothing has been spent yet \u2014 `balance` is untouched and\n`available` dropped, so the same user cannot start ten more renders on credits they do not have.\n\nWhen the run reaches a terminal state, vxil settles it for you, and the ledger says which way it\nwent:\n\n- **completed** \u2192 the provisional row flips to `committed` and a `settle` row lands with\n `source: "job:succeeded"`. The credits were spent.\n- **failed / timed out** \u2192 a `reversal` row lands with a **positive** delta and\n `source: "job:failed"`, the original flips to `reversed`, and `held` returns to zero. The\n customer was not charged for work that did not happen. That refund is the\n `ledger.autoRefundOnJobFailure` flag in `vxil.config.ts`; turn it off and a failure still releases\n the hold but keeps the charge.\n\nTwo ceilings you do not have to remember to set: a single run\'s request is **clamped** to\n`generation.maxReserveCredits` (never rejected, so a bad caller cannot break the flow), and the sum\nof all outstanding holds is refused past `generation.maxOutstandingReserveCredits` with a 429. Both\nhave safe defaults.\n\nPoll mode is exactly what it says: vxil calls your provider once, then re-reads\n`completion.poll.url` every `interval_ms`, reading `status_path` out of the body. Anything it does\nnot recognise counts as *still processing* \u2014 a generation is never silently completed by a typo.\nAfter `pollMaxAttempts` it fails terminally, and the timeout does the same on the wall clock.\n\n**6. Dead letters and replay.** There is no separate dead-letter inbox \u2014 a dead letter is a run in\nthe `dead` state:\n\n```bash\nvxil api GET "/v1/jobs/runs?state=dead&limit=20"\n# 200 { "data": { "runs": [ { "run_id": "run_\u2026", "job_name": "report.email", "state": "dead",\n# "attempt_number": 1, "max_attempts": 1,\n# "last_error_class": "NonRetryableHttp",\n# "last_error_msg": "target returned 404", \u2026 } ] } }\n\nvxil api POST /v1/jobs/runs/run_\u2026/replay\n# 202 { "data": { "run_id": "run_NEW", "replayed_from": "run_\u2026", "state": "queued" } }\n```\n\nReplay takes **no body** and clones the original into a *new* run \u2014 the dead row is evidence and\nstays untouched. Only terminal runs replay; anything still in flight is a `409 not_replayable`.\n\n**7. Failures become rows.** `incident-watch` runs every five minutes. Force it once:\n\n```bash\nvxil functions invoke incident-watch\n# first run: { "initialized": true, "cursor": "\u2026", "raised": 0 } \u2190 history never floods you\n# after a dead letter, the next run:\n# { "scanned": 12, "raised": 1, "resolved": 0, "cursor": "\u2026" }\n\nvxil api GET /v1/cms/items/incidents\n# 200 \u2026 { "key": "jobs:dead-letter:report.email", "event": "job.dead_lettered", "level": "error",\n# "subject": "report.email", "seen_count": 1, "first_seen": "\u2026", "last_seen": "\u2026",\n# "detail": "run run_\u2026 died after attempt 1" }\n```\n\nTen dead letters of the same job produce **one** row with `seen_count: 10`, because the incident\nkey collapses them. `jobs.schedule.missed` and `jobs.schedule.recovered` share a key, so a schedule\nthat catches up closes its own incident. The `RULES` table in `functions/incident-watch.ts` is the\nallow-list \u2014 plain data. Widen it from the catalog of everything the platform can emit:\n\n```bash\nvxil api GET /v1/webhooks/events/catalog\n# 200 { "data": { "count": 170, "events": [ { "name": "job.dead_lettered", "feature": "jobs",\n# "level": "failure", "payload_keys": [ \u2026 ] } \u2026 ],\n# "prefixes": [ { "prefix": "payments.", "count": 21 }, \u2026 ] } } \u2190 34 prefixes\n```\n\nIf you would rather the same events went to **your own** endpoint than into a collection, subscribe\nto the spine directly \u2014 same events, different consumer:\n\n```bash\nvxil api POST /v1/webhooks/subscriptions --data \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["job.","jobs."]}\'\n```\n\n## What to learn from this\n\n- **Exactly-once is not a delivery guarantee you can buy; it is a field you declare.** `unique` on\n `request_key`, `idempotency_key` on the enqueue, `foreign_id` elsewhere \u2014 each converts\n at-least-once delivery into an at-most-once *effect*.\n- **A reservation is not a charge.** Holding credits while long work runs, and releasing them if it\n fails, is the difference between metering and billing people for your outages. The ledger shows\n both halves, so you can answer "why was I charged?" from a query.\n- **Failure needs a vocabulary, not a log.** `job.dead_lettered`, `jobs.schedule.missed`,\n `job.generation.failed` are named events with stable payloads \u2014 that is why a 40-line function can\n turn them into an incident board, and why the catalog route can tell an agent what exists.\n- **Late is a different failure from broken.** A schedule that fires twice its interval late says so\n once, and says so again when it recovers. Alerting on every tick teaches people to mute you.\n- **The clamp beats the rejection.** Capping a requested hold, rather than refusing it, keeps a\n careless caller from breaking the flow while still bounding the blast radius.\n\n**Pairs with:** `templates/alerts-to-slack/` (the same drain, routed to a chat channel instead of a\ncollection) and `templates/payments-heartbeat/` (noticing the failure that is *silence*).\n',
17462
18325
  "functions": {
17463
- "incident-watch.ts": "// incident-watch.ts \u2014 FAILURE EVENTS \u2192 `incidents` ROWS (a vxil function).\n//\n// Trigger: cron `*/5 * * * *`. Every feature writes its lifecycle to your\n// tenant's AUDIT STREAM \u2014 that stream is the event spine, and this function is\n// one consumer of it. Each run:\n// 1. reads the watermark (the reserved `__cursor__` row in `incidents`; the\n// first run stores the NEWEST id and stops, so history never floods you),\n// 2. drains `GET /v1/audit/export?after_id=\u2026` (ascending NDJSON) past it,\n// 3. matches each row against RULES \u2014 an allow-list, not a firehose,\n// 4. upserts ONE row per incident key (ten dead letters of the same job are\n// one row with `seen_count: 10`, not ten rows),\n// 5. advances the watermark with If-Match, so an overlapping tick stands down\n// instead of double-counting.\n//\n// WHY A KEY: the audit stream is a platform read, not one of the feature APIs\n// the function's scoped callback covers \u2014 so the drain uses the narrowest key\n// that can reach it, one holding ONLY `features:read`, stored as a secret and\n// revocable without a redeploy.\n//\n// To fan the same events out to YOUR OWN https endpoint instead, subscribe:\n// POST /v1/webhooks/subscriptions { \"target_url\": \"\u2026\", \"event_prefixes\": [\"job.\"] }\n// Both ride the same spine; this one keeps the state inside your backend.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_PAGES_PER_RUN = 5; // \xD7 500 rows \u2014 bounds one tick\nconst DETAIL_MAX = 240;\n\ntype Level = 'info' | 'warn' | 'error';\ntype Payload = Record<string, unknown>;\ninterface Rule {\n level: Level;\n key: (p: Payload) => string;\n subject: (p: Payload) => string;\n detail: (p: Payload) => string;\n /** a healing event: clears the row rather than incrementing it */\n resolves?: boolean;\n}\n\nconst str = (v: unknown, fallback = 'unknown') => (typeof v === 'string' && v ? v : fallback);\nconst num = (v: unknown) => (typeof v === 'number' ? v : null);\n\n/** THE ALLOW-LIST. Every name below is an audit event the jobs feature writes\n * today; anything not listed is ignored. Own this table \u2014 it is data, not a\n * routing engine. `GET /v1/webhooks/events/catalog` lists every event the\n * platform can emit if you want to widen it. */\nconst RULES: Record<string, Rule> = {\n // a run exhausted its retries\n 'job.dead_lettered': {\n level: 'error',\n key: (p) => `jobs:dead-letter:${str(p.job_name)}`,\n subject: (p) => str(p.job_name),\n detail: (p) => `run ${str(p.run_id)} died after attempt ${num(p.attempt) ?? '?'}`,\n },\n // the tenant burned its daily dead-letter budget \u2014 a louder, rarer signal\n 'job.dead_letter_quota_exceeded': {\n level: 'error',\n key: () => 'jobs:dead-letter-quota',\n subject: (p) => str(p.job_name),\n detail: (p) => `daily dead-letter quota spent; run ${str(p.run_id)} skipped its retries`,\n },\n // a schedule fired more than 2\xD7 its own interval late\n 'jobs.schedule.missed': {\n level: 'warn',\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n subject: (p) => str(p.job_name),\n detail: (p) =>\n `expected ${str(p.expected_at)}, observed ${str(p.observed_at)} `\n + `(${num(p.late_seconds) ?? '?'}s late on a ${num(p.interval_seconds) ?? '?'}s interval)`,\n },\n // \u2026and its healing twin, sharing the SAME key, so recovery closes the row\n 'jobs.schedule.recovered': {\n level: 'info',\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n subject: (p) => str(p.job_name),\n detail: (p) => `back on time at ${str(p.observed_at)}`,\n resolves: true,\n },\n // a generation run ended in a terminal failure (its held credits are released)\n 'job.generation.failed': {\n level: 'error',\n key: (p) => `jobs:generation:${str(p.error_class, 'unclassified')}`,\n subject: (p) => str(p.error_class, 'unclassified'),\n detail: (p) => `run ${str(p.run_id)} failed (${str(p.error_class, 'no error class')})`,\n },\n};\n\ninterface AuditRow { id?: string | number; event?: string; created_at?: string; payload?: Payload }\ninterface Item { item_id: string; version: number; data: Record<string, unknown> }\n// the schedule tick, or a hand invoke (`POST /v1/fn/incident-watch`) carrying `{ dry_run }`\ntype Env = CronFunctionEnvelope | HttpFunctionEnvelope<{ dry_run?: boolean }>;\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 const readKey = env.secrets?.vxil_read_key;\n if (!cms || !readKey) {\n return Response.json({ error: 'missing cms scope or vxil_read_key secret' }, { status: 503 });\n }\n const store = new Store(base, cms);\n\n // 1. the watermark\n const cursorRow = await store.byKey('__cursor__');\n if (!cursorRow) {\n const newest = await newestAuditId(base, readKey);\n await store.create({ key: '__cursor__', cursor: newest, last_seen: new Date().toISOString() });\n return Response.json({ initialized: true, cursor: newest, raised: 0 });\n }\n let cursor = String(cursorRow.data.cursor ?? '0');\n\n // 2. drain\n const rows: AuditRow[] = [];\n for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {\n const { rows: batch, next } = await drain(base, readKey, cursor);\n rows.push(...batch);\n if (batch.length > 0) cursor = String(batch[batch.length - 1]!.id ?? cursor);\n if (!next || batch.length === 0) break;\n cursor = next;\n }\n\n // 3 + 4. match and upsert\n let raised = 0;\n let resolved = 0;\n const now = new Date().toISOString();\n for (const row of rows) {\n const rule = RULES[String(row.event ?? '')];\n if (!rule) continue;\n const p = row.payload ?? {};\n const key = rule.key(p);\n const existing = await store.byKey(key);\n\n if (rule.resolves) {\n if (existing) {\n await store.patch(existing, {\n level: 'info', event: String(row.event), detail: rule.detail(p).slice(0, DETAIL_MAX),\n last_seen: now,\n });\n resolved++;\n }\n continue;\n }\n if (existing) {\n await store.patch(existing, {\n event: String(row.event),\n level: rule.level,\n subject: rule.subject(p),\n seen_count: Number(existing.data.seen_count ?? 0) + 1,\n detail: rule.detail(p).slice(0, DETAIL_MAX),\n last_seen: now,\n });\n } else {\n await store.create({\n key,\n event: String(row.event),\n level: rule.level,\n subject: rule.subject(p),\n seen_count: 1,\n first_seen: now,\n last_seen: now,\n detail: rule.detail(p).slice(0, DETAIL_MAX),\n });\n }\n raised++;\n }\n\n // 5. advance \u2014 If-Match, so exactly one overlapping run wins\n if (!env.payload?.dry_run) {\n await store.patch(cursorRow, { cursor, last_seen: now });\n }\n return Response.json({ scanned: rows.length, raised, resolved, cursor });\n },\n};\n\n// \u2500\u2500 the audit stream (ascending NDJSON; `x-vxil-next-after-id` continues it) \u2500\u2500\nasync function drain(\n base: string, key: string, afterId: string,\n): Promise<{ rows: AuditRow[]; next: string | null }> {\n const res = await fetch(\n `${base}/v1/audit/export?after_id=${encodeURIComponent(afterId)}&limit=500`,\n { headers: { authorization: `Bearer ${key}` } },\n );\n if (!res.ok) throw new Error(`audit export ${res.status}`);\n const rows = (await res.text()).split('\\n').filter(Boolean).map((l) => JSON.parse(l) as AuditRow);\n return { rows, next: res.headers.get('x-vxil-next-after-id') };\n}\nasync function newestAuditId(base: string, key: string): Promise<string> {\n const res = await fetch(`${base}/v1/audit?limit=1`, { headers: { authorization: `Bearer ${key}` } });\n if (!res.ok) throw new Error(`audit list ${res.status}`);\n const body = (await res.json()) as { data?: { events?: AuditRow[] } };\n return String(body.data?.events?.[0]?.id ?? '0');\n}\n\n// \u2500\u2500 the cms store (the REST envelope is { data: { items: [{ item_id, version, data }] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() {\n return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' };\n }\n async byKey(key: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${this.base}/v1/cms/items/incidents?filter=${filter}&limit=1`, {\n headers: this.h(),\n });\n if (!res.ok) return null;\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 \u2014 a concurrent run already claimed this unique key */\n async create(data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/incidents`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n /** false on 409 \u2014 a concurrent run already moved this row past `version` */\n async patch(item: Item, data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/incidents/${item.item_id}`, {\n method: 'PATCH',\n headers: { ...this.h(), 'if-match': String(item.version) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n",
18326
+ "incident-watch.ts": "// incident-watch.ts \u2014 FAILURE EVENTS \u2192 `incidents` ROWS (a vxil function).\n//\n// Trigger: cron `*/5 * * * *`. Every feature writes its lifecycle to your\n// tenant's AUDIT STREAM \u2014 that stream is the event spine, and this function is\n// one consumer of it. Each run:\n// 1. reads the watermark (the reserved `__cursor__` row in `incidents`; the\n// first run stores the NEWEST id and stops, so history never floods you),\n// 2. drains `GET /v1/audit/export?after_id=\u2026` (ascending NDJSON) past it,\n// 3. matches each row against RULES \u2014 an allow-list, not a firehose,\n// 4. upserts ONE row per incident key (ten dead letters of the same job are\n// one row with `seen_count: 10`, not ten rows),\n// 5. advances the watermark with If-Match, so an overlapping tick stands down\n// instead of double-counting.\n//\n// WHY A KEY: the audit stream is a platform read, not one of the feature APIs\n// the function's scoped callback covers \u2014 so the drain uses the narrowest key\n// that can reach it, one holding ONLY `features:read`, stored as a secret and\n// revocable without a redeploy.\n//\n// To fan the same events out to YOUR OWN https endpoint instead, subscribe:\n// POST /v1/webhooks/subscriptions { \"target_url\": \"\u2026\", \"event_prefixes\": [\"job.\"] }\n// Both ride the same spine; this one keeps the state inside your backend.\n\n// cron-walk: persisted-cursor \u2014 the audit watermark lives in the `__cursor__` row and advances with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_PAGES_PER_RUN = 5; // \xD7 500 rows \u2014 bounds one tick\nconst DETAIL_MAX = 240;\n\ntype Level = 'info' | 'warn' | 'error';\ntype Payload = Record<string, unknown>;\ninterface Rule {\n level: Level;\n key: (p: Payload) => string;\n subject: (p: Payload) => string;\n detail: (p: Payload) => string;\n /** a healing event: clears the row rather than incrementing it */\n resolves?: boolean;\n}\n\nconst str = (v: unknown, fallback = 'unknown') => (typeof v === 'string' && v ? v : fallback);\nconst num = (v: unknown) => (typeof v === 'number' ? v : null);\n\n/** THE ALLOW-LIST. Every name below is an audit event the jobs feature writes\n * today; anything not listed is ignored. Own this table \u2014 it is data, not a\n * routing engine. `GET /v1/webhooks/events/catalog` lists every event the\n * platform can emit if you want to widen it. */\nconst RULES: Record<string, Rule> = {\n // a run exhausted its retries\n 'job.dead_lettered': {\n level: 'error',\n key: (p) => `jobs:dead-letter:${str(p.job_name)}`,\n subject: (p) => str(p.job_name),\n detail: (p) => `run ${str(p.run_id)} died after attempt ${num(p.attempt) ?? '?'}`,\n },\n // the tenant burned its daily dead-letter budget \u2014 a louder, rarer signal\n 'job.dead_letter_quota_exceeded': {\n level: 'error',\n key: () => 'jobs:dead-letter-quota',\n subject: (p) => str(p.job_name),\n detail: (p) => `daily dead-letter quota spent; run ${str(p.run_id)} skipped its retries`,\n },\n // a schedule fired more than 2\xD7 its own interval late\n 'jobs.schedule.missed': {\n level: 'warn',\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n subject: (p) => str(p.job_name),\n detail: (p) =>\n `expected ${str(p.expected_at)}, observed ${str(p.observed_at)} `\n + `(${num(p.late_seconds) ?? '?'}s late on a ${num(p.interval_seconds) ?? '?'}s interval)`,\n },\n // \u2026and its healing twin, sharing the SAME key, so recovery closes the row\n 'jobs.schedule.recovered': {\n level: 'info',\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n subject: (p) => str(p.job_name),\n detail: (p) => `back on time at ${str(p.observed_at)}`,\n resolves: true,\n },\n // a generation run ended in a terminal failure (its held credits are released)\n 'job.generation.failed': {\n level: 'error',\n key: (p) => `jobs:generation:${str(p.error_class, 'unclassified')}`,\n subject: (p) => str(p.error_class, 'unclassified'),\n detail: (p) => `run ${str(p.run_id)} failed (${str(p.error_class, 'no error class')})`,\n },\n};\n\ninterface AuditRow { id?: string | number; event?: string; created_at?: string; payload?: Payload }\ninterface Item { item_id: string; version: number; data: Record<string, unknown> }\n// the schedule tick, or a hand invoke (`POST /v1/fn/incident-watch`) carrying `{ dry_run }`\ntype Env = CronFunctionEnvelope | HttpFunctionEnvelope<{ dry_run?: boolean }>;\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 const readKey = env.secrets?.vxil_read_key;\n if (!cms || !readKey) {\n return Response.json({ error: 'missing cms scope or vxil_read_key secret' }, { status: 503 });\n }\n const store = new Store(base, cms);\n\n // 1. the watermark\n const cursorRow = await store.byKey('__cursor__');\n if (!cursorRow) {\n const newest = await newestAuditId(base, readKey);\n await store.create({ key: '__cursor__', cursor: newest, last_seen: new Date().toISOString() });\n return Response.json({ initialized: true, cursor: newest, raised: 0 });\n }\n let cursor = String(cursorRow.data.cursor ?? '0');\n\n // 2. drain\n const rows: AuditRow[] = [];\n for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {\n const { rows: batch, next } = await drain(base, readKey, cursor);\n rows.push(...batch);\n if (batch.length > 0) cursor = String(batch[batch.length - 1]!.id ?? cursor);\n if (!next || batch.length === 0) break;\n cursor = next;\n }\n\n // 3 + 4. match and upsert\n let raised = 0;\n let resolved = 0;\n const now = new Date().toISOString();\n for (const row of rows) {\n const rule = RULES[String(row.event ?? '')];\n if (!rule) continue;\n const p = row.payload ?? {};\n const key = rule.key(p);\n const existing = await store.byKey(key);\n\n if (rule.resolves) {\n if (existing) {\n await store.patch(existing, {\n level: 'info', event: String(row.event), detail: rule.detail(p).slice(0, DETAIL_MAX),\n last_seen: now,\n });\n resolved++;\n }\n continue;\n }\n if (existing) {\n await store.patch(existing, {\n event: String(row.event),\n level: rule.level,\n subject: rule.subject(p),\n seen_count: Number(existing.data.seen_count ?? 0) + 1,\n detail: rule.detail(p).slice(0, DETAIL_MAX),\n last_seen: now,\n });\n } else {\n await store.create({\n key,\n event: String(row.event),\n level: rule.level,\n subject: rule.subject(p),\n seen_count: 1,\n first_seen: now,\n last_seen: now,\n detail: rule.detail(p).slice(0, DETAIL_MAX),\n });\n }\n raised++;\n }\n\n // 5. advance \u2014 If-Match, so exactly one overlapping run wins\n if (!env.payload?.dry_run) {\n await store.patch(cursorRow, { cursor, last_seen: now });\n }\n return Response.json({ scanned: rows.length, raised, resolved, cursor });\n },\n};\n\n// \u2500\u2500 the audit stream (ascending NDJSON; `x-vxil-next-after-id` continues it) \u2500\u2500\nasync function drain(\n base: string, key: string, afterId: string,\n): Promise<{ rows: AuditRow[]; next: string | null }> {\n const res = await fetch(\n `${base}/v1/audit/export?after_id=${encodeURIComponent(afterId)}&limit=500`,\n { headers: { authorization: `Bearer ${key}` } },\n );\n if (!res.ok) throw new Error(`audit export ${res.status}`);\n const rows = (await res.text()).split('\\n').filter(Boolean).map((l) => JSON.parse(l) as AuditRow);\n return { rows, next: res.headers.get('x-vxil-next-after-id') };\n}\nasync function newestAuditId(base: string, key: string): Promise<string> {\n const res = await fetch(`${base}/v1/audit?limit=1`, { headers: { authorization: `Bearer ${key}` } });\n if (!res.ok) throw new Error(`audit list ${res.status}`);\n const body = (await res.json()) as { data?: { events?: AuditRow[] } };\n return String(body.data?.events?.[0]?.id ?? '0');\n}\n\n// \u2500\u2500 the cms store (the REST envelope is { data: { items: [{ item_id, version, data }] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() {\n return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' };\n }\n async byKey(key: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${this.base}/v1/cms/items/incidents?filter=${filter}&limit=1`, {\n headers: this.h(),\n });\n if (!res.ok) return null;\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 \u2014 a concurrent run already claimed this unique key */\n async create(data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/incidents`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n /** false on 409 \u2014 a concurrent run already moved this row past `version` */\n async patch(item: Item, data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/incidents/${item.item_id}`, {\n method: 'PATCH',\n headers: { ...this.h(), 'if-match': String(item.version) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n",
17464
18327
  "process-batch.ts": "// process-batch.ts \u2014 THE WORKER (a vxil function).\n//\n// Trigger: queue. This function has no URL a browser can reach \u2014 it runs because\n// something was ENQUEUED. The envelope it receives is:\n// { trigger: 'queue', tenant_id, request_id, idempotency_key, vxil_base,\n// scoped_jwts, secrets, payload }\n// where `payload` is exactly the object you put inside the enqueue body's\n// `payload.payload`, and `idempotency_key` is stable per run.\n//\n// DELIVERY IS AT-LEAST-ONCE. A retry, an overlapping tick, or a replay can hand\n// you the same batch twice, so correctness cannot rest on \"it runs once\". Here\n// the `request_key` field on `renders` is declared `unique`, which turns the\n// second create into a clean 409 \u2014 and a 409 is not an error, it is the answer\n// \"already done\". That is the whole dedupe strategy: one declared field, and a\n// status code you agree to read as success.\n\nimport type { QueueFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = QueueFunctionEnvelope<{ batch_id?: string; items?: Array<{ key?: string; title?: string; credits?: number }> }>;\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\n const batchId = String(env.payload?.batch_id ?? env.idempotency_key ?? 'batch');\n const items = Array.isArray(env.payload?.items) ? env.payload!.items! : [];\n if (items.length === 0) return Response.json({ batch_id: batchId, created: 0, duplicates: 0 });\n\n let created = 0;\n let duplicates = 0;\n let rejected = 0;\n\n for (const [i, item] of items.entries()) {\n // Derive a stable key per item so the SAME batch always produces the SAME\n // keys \u2014 that is what makes the redelivery a duplicate rather than a copy.\n const requestKey = String(item.key ?? `${batchId}:${i}`);\n const res = await fetch(`${base}/v1/cms/items/renders`, {\n method: 'POST',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: {\n request_key: requestKey,\n title: String(item.title ?? requestKey),\n state: 'queued',\n run_id: env.idempotency_key ?? null,\n credits: typeof item.credits === 'number' ? item.credits : 0,\n created_at: new Date().toISOString(),\n },\n }),\n });\n if (res.ok) created++;\n else if (res.status === 409) duplicates++; // already processed \u2014 the point of `unique`\n else rejected++;\n }\n\n return Response.json({ batch_id: batchId, created, duplicates, rejected });\n },\n};\n"
17465
18328
  }
17466
18329
  },
@@ -18031,9 +18894,9 @@ function coerceCsvCell(v, type) {
18031
18894
  }
18032
18895
 
18033
18896
  // src/migrate/adapters/generic-pg.ts
18034
- var IDENT_RE = /^[a-z_][a-z0-9_]*$/i;
18035
- function quoteIdent(name) {
18036
- if (!IDENT_RE.test(name)) throw new Error(`unsafe identifier '${name}' \u2014 only [A-Za-z_][A-Za-z0-9_]* is readable`);
18897
+ var IDENT_RE2 = /^[a-z_][a-z0-9_]*$/i;
18898
+ function quoteIdent2(name) {
18899
+ if (!IDENT_RE2.test(name)) throw new Error(`unsafe identifier '${name}' \u2014 only [A-Za-z_][A-Za-z0-9_]* is readable`);
18037
18900
  return `"${name}"`;
18038
18901
  }
18039
18902
  function redactSourceUrl(url) {
@@ -18220,17 +19083,17 @@ var catalogSql = {
18220
19083
  })
18221
19084
  };
18222
19085
  function changedSinceSql(column, n) {
18223
- return `(${quoteIdent(column)} >= $${n}::timestamptz OR ${quoteIdent(column)} IS NULL)`;
19086
+ return `(${quoteIdent2(column)} >= $${n}::timestamptz OR ${quoteIdent2(column)} IS NULL)`;
18224
19087
  }
18225
19088
  function keysetPageSql(schema, table, columns, pk, after, limit, changedSince) {
18226
- const cols = columns.map(quoteIdent).join(", ");
18227
- const from = `${quoteIdent(schema)}.${quoteIdent(table)}`;
19089
+ const cols = columns.map(quoteIdent2).join(", ");
19090
+ const from = `${quoteIdent2(schema)}.${quoteIdent2(table)}`;
18228
19091
  const n = Math.max(1, Math.trunc(limit));
18229
19092
  const conds = [];
18230
19093
  const params = [];
18231
19094
  if (after !== null) {
18232
19095
  params.push(after);
18233
- conds.push(`${quoteIdent(pk)} > $${params.length}`);
19096
+ conds.push(`${quoteIdent2(pk)} > $${params.length}`);
18234
19097
  }
18235
19098
  if (changedSince) {
18236
19099
  params.push(changedSince.since);
@@ -18238,14 +19101,14 @@ function keysetPageSql(schema, table, columns, pk, after, limit, changedSince) {
18238
19101
  }
18239
19102
  const where = conds.length ? ` WHERE ${conds.join(" AND ")}` : "";
18240
19103
  return {
18241
- text: `SELECT ${cols} FROM ${from}${where} ORDER BY ${quoteIdent(pk)} ASC LIMIT ${n}`,
19104
+ text: `SELECT ${cols} FROM ${from}${where} ORDER BY ${quoteIdent2(pk)} ASC LIMIT ${n}`,
18242
19105
  params
18243
19106
  };
18244
19107
  }
18245
19108
  function offsetPageSql(schema, table, columns, orderBy, offset, limit, changedSince) {
18246
- const cols = columns.map(quoteIdent).join(", ");
18247
- const from = `${quoteIdent(schema)}.${quoteIdent(table)}`;
18248
- const order = orderBy.map(quoteIdent).join(", ");
19109
+ const cols = columns.map(quoteIdent2).join(", ");
19110
+ const from = `${quoteIdent2(schema)}.${quoteIdent2(table)}`;
19111
+ const order = orderBy.map(quoteIdent2).join(", ");
18249
19112
  const n = Math.max(1, Math.trunc(limit));
18250
19113
  const off = Math.max(0, Math.trunc(offset));
18251
19114
  const where = changedSince ? ` WHERE ${changedSinceSql(changedSince.column, 1)}` : "";
@@ -18276,7 +19139,7 @@ var GenericPgAdapter = class {
18276
19139
  statementTimeoutMs;
18277
19140
  constructor(opts) {
18278
19141
  this.schemas = opts.schemas && opts.schemas.length ? opts.schemas : ["public"];
18279
- for (const s of this.schemas) quoteIdent(s);
19142
+ for (const s of this.schemas) quoteIdent2(s);
18280
19143
  this.batchSize = opts.batchSize ?? 500;
18281
19144
  this.statementTimeoutMs = Math.trunc(opts.statementTimeoutMs ?? 12e4);
18282
19145
  this.authTable = opts.authTable;
@@ -18350,7 +19213,7 @@ var GenericPgAdapter = class {
18350
19213
  for (const r of tablesR) {
18351
19214
  const schema2 = r.table_schema;
18352
19215
  const name = r.table_name;
18353
- if (!IDENT_RE.test(name)) {
19216
+ if (!IDENT_RE2.test(name)) {
18354
19217
  warnings.push(`table "${schema2}"."${name}" skipped \u2014 name is not a safe identifier`);
18355
19218
  continue;
18356
19219
  }
@@ -18363,7 +19226,7 @@ var GenericPgAdapter = class {
18363
19226
  const columns = [];
18364
19227
  for (const c of colsByTable.get(key) ?? []) {
18365
19228
  const colName = c.column_name;
18366
- if (!IDENT_RE.test(colName)) {
19229
+ if (!IDENT_RE2.test(colName)) {
18367
19230
  warnings.push(`${name}.${colName} skipped \u2014 column name is not a safe identifier`);
18368
19231
  continue;
18369
19232
  }
@@ -18591,13 +19454,26 @@ var supabaseSql = {
18591
19454
  text: `SELECT count(*)::bigint AS n FROM auth.users`,
18592
19455
  params: []
18593
19456
  }),
19457
+ /** W14 — the ONE statement that reads a credential: GoTrue's bcrypt
19458
+ * `encrypted_password` ('' for a user with no password, e.g. OAuth-only)
19459
+ * plus whether the address was confirmed, keyset on the uuid id. Run only
19460
+ * under `--with-password-hashes` / `--oidc-issuer`; soft-deleted users are
19461
+ * not read (`deleted_at` exists on current GoTrue — pass false when absent). */
19462
+ credentialsPage: (after, limit, hasDeletedAt) => {
19463
+ const n = Math.max(1, Math.trunc(limit));
19464
+ const conds = [...after !== null ? ["id > $1::uuid"] : [], ...hasDeletedAt ? ["deleted_at IS NULL"] : []];
19465
+ return {
19466
+ text: `SELECT id::text AS id, encrypted_password, (email_confirmed_at IS NOT NULL) AS email_verified FROM auth.users${conds.length ? ` WHERE ${conds.join(" AND ")}` : ""} ORDER BY id ASC LIMIT ${n}`,
19467
+ params: after !== null ? [after] : []
19468
+ };
19469
+ },
18594
19470
  /** does this database carry the Storage schema? */
18595
19471
  storageProbe: () => ({
18596
19472
  text: `SELECT to_regclass('storage.buckets') IS NOT NULL AS present`,
18597
19473
  params: []
18598
19474
  }),
18599
19475
  buckets: () => ({
18600
- text: `SELECT name FROM storage.buckets ORDER BY name`,
19476
+ text: `SELECT name, public FROM storage.buckets ORDER BY name`,
18601
19477
  params: []
18602
19478
  }),
18603
19479
  bucketObjectCounts: () => ({
@@ -18652,7 +19528,7 @@ var SupabaseAdapter = class extends GenericPgAdapter {
18652
19528
  schema.buckets = (await this.q(supabaseSql.buckets())).map((r) => {
18653
19529
  const name = r.name;
18654
19530
  const c = counts.get(name);
18655
- return c !== void 0 ? { name, objectCount: c } : { name };
19531
+ return { name, ...c !== void 0 ? { objectCount: c } : {}, public: r.public === true };
18656
19532
  });
18657
19533
  }
18658
19534
  } catch {
@@ -18681,6 +19557,26 @@ var SupabaseAdapter = class extends GenericPgAdapter {
18681
19557
  after = next.slice(2);
18682
19558
  }
18683
19559
  }
19560
+ /** W14 credential pages (supabaseSql.credentialsPage). The rows hold bcrypt
19561
+ * hashes: never logged, never written anywhere but the import request. */
19562
+ async *readCredentials(cursor) {
19563
+ const source = await this.introspect();
19564
+ if (!source.authUsers?.present) throw new Error("auth.users not present on this source");
19565
+ const cols = source.authUsers.columns ?? [];
19566
+ if (!cols.includes("encrypted_password")) throw new Error("auth.users has no encrypted_password column \u2014 nothing to import");
19567
+ let after = cursor !== void 0 && cursor !== "" ? cursor : null;
19568
+ for (; ; ) {
19569
+ const rows = await this.q(supabaseSql.credentialsPage(after, this.batchSize, cols.includes("deleted_at")));
19570
+ const out = rows.map((r) => {
19571
+ const h = typeof r.encrypted_password === "string" && r.encrypted_password.length > 0 ? r.encrypted_password : null;
19572
+ return { id: String(r.id), password_hash: h, email_verified: r.email_verified === true };
19573
+ });
19574
+ const next = rows.length === this.batchSize ? String(rows[rows.length - 1].id) : null;
19575
+ yield { rows: out, nextCursor: next };
19576
+ if (next === null) return;
19577
+ after = next;
19578
+ }
19579
+ }
18684
19580
  /** stream one bucket's objects: rows from storage.objects, bytes from the
18685
19581
  * Storage HTTP API (lazy per object — piped straight into the files PUT).
18686
19582
  * (No `override` — the base class does not implement the optional
@@ -18823,7 +19719,7 @@ async function prompt(q, opts = {}) {
18823
19719
  stdin.on("data", onData);
18824
19720
  });
18825
19721
  }
18826
- const rl = createInterface({ input: process.stdin, output: process.stderr });
19722
+ const rl = createInterface2({ input: process.stdin, output: process.stderr });
18827
19723
  return await new Promise((res) => rl.question(q, (a) => {
18828
19724
  rl.close();
18829
19725
  res(a.trim());
@@ -18986,7 +19882,38 @@ function requireApi(sel = "prod", opts = {}) {
18986
19882
  const t = resolveTargetOrFail(sel, opts.exitCode ?? 1);
18987
19883
  if (!t.apiKey) fail(noKeyMessage(t), opts.exitCode ?? 1);
18988
19884
  if (opts.write) announceTarget(t, opts.write);
18989
- return { api: makeApi({ apiKey: t.apiKey, baseUrl: t.baseUrl }), baseUrl: t.baseUrl, slug: t.tenantSlug, target: t };
19885
+ const raw = makeApi({ apiKey: t.apiKey, baseUrl: t.baseUrl });
19886
+ let checked = null;
19887
+ const api = async (method, path, body, headers) => {
19888
+ if (opts.write || method.toUpperCase() !== "GET") {
19889
+ checked ??= assertKeyTenant(t, raw);
19890
+ await checked;
19891
+ }
19892
+ return raw(method, path, body, headers);
19893
+ };
19894
+ return { api, baseUrl: t.baseUrl, slug: t.tenantSlug, target: t };
19895
+ }
19896
+ function slotWhere(t) {
19897
+ return t.slot === "named" ? `target '${t.slotName}'` : t.slot === "dev" ? "the dev slot" : t.slot === "env" ? "VXIL_API_KEY" : "the primary binding";
19898
+ }
19899
+ async function assertKeyTenant(t, api, exitCode = 1) {
19900
+ if (!knownTenantId(t.tenantId)) return;
19901
+ let read;
19902
+ try {
19903
+ read = await api("GET", "/v1/features");
19904
+ } catch (e) {
19905
+ read = e;
19906
+ }
19907
+ const v = judgeKeyTenant(t.tenantId, read);
19908
+ if (v.kind === "mismatch") {
19909
+ fail(keyTenantRefusal(v, {
19910
+ where: slotWhere(t),
19911
+ slug: t.tenantSlug,
19912
+ keySource: t.keySource,
19913
+ envLabel: t.envLabel,
19914
+ as: t.slot === "dev" ? "dev" : t.slot === "named" ? t.slotName : void 0
19915
+ }), exitCode);
19916
+ }
18990
19917
  }
18991
19918
  var ExportPageError = class extends Error {
18992
19919
  };
@@ -19097,7 +20024,7 @@ async function exportNdjson(feature, opts = { urls: false, paceMs: 0 }) {
19097
20024
  if (!t.apiKey) fail(noKeyMessage(t));
19098
20025
  const inc = opts.urls ? "&include=urls" : "";
19099
20026
  const pg = exportPager({ baseUrl: t.baseUrl, apiKey: t.apiKey }, opts.paceMs);
19100
- const out = flag("out");
20027
+ const out = opts.out ?? flag("out");
19101
20028
  const started = Date.now();
19102
20029
  const tally = newDownloadTally();
19103
20030
  let sink = null;
@@ -19109,6 +20036,7 @@ async function exportNdjson(feature, opts = { urls: false, paceMs: 0 }) {
19109
20036
  written += lines.length;
19110
20037
  };
19111
20038
  let resume = [];
20039
+ const unresumable = [];
19112
20040
  try {
19113
20041
  const first = await exportPage(pg, `/v1/export/${feature}?format=ndjson${inc}`, opts.urls);
19114
20042
  sink = openExportSink(out);
@@ -19131,7 +20059,7 @@ async function exportNdjson(feature, opts = { urls: false, paceMs: 0 }) {
19131
20059
  for (let i = 0; i < truncated.length; i++) {
19132
20060
  const tr = truncated[i];
19133
20061
  if (!tr.after) {
19134
- console.error(`vxil: note \u2014 table '${tr.table}' hit the server row cap and has no resumable primary key; remaining rows not included`);
20062
+ unresumable.push(tr.table);
19135
20063
  continue;
19136
20064
  }
19137
20065
  let after = tr.after;
@@ -19155,6 +20083,102 @@ async function exportNdjson(feature, opts = { urls: false, paceMs: 0 }) {
19155
20083
  sink.commit();
19156
20084
  if (out) console.log(`exported ${feature} \u2192 ${out} (${written} lines)`);
19157
20085
  if (opts.urls) reportDownloads(tally, started, sink.readLive);
20086
+ if (unresumable.length) {
20087
+ console.error(incompleteTablesMessage(feature, unresumable, false));
20088
+ process.exitCode = worseExportExit(process.exitCode, EXPORT_EXIT_INCOMPLETE);
20089
+ }
20090
+ }
20091
+ async function exportPasswordHashes(feature, outOverride) {
20092
+ if (feature !== "auth") fail("--include-password-hashes applies to `vxil export auth` only");
20093
+ const out = outOverride ?? flag("out");
20094
+ if (!out) fail("--include-password-hashes needs --out <file> (the hashes are never printed to the terminal)");
20095
+ const { t, slug, dash, run } = boundProjectSession();
20096
+ console.error(CREDENTIAL_EXPORT_NOTE);
20097
+ await confirmTyped("export password hashes", `Type the project slug '${slug}' to export its password hashes: `, slug);
20098
+ announceTarget(t, "export auth --include-password-hashes");
20099
+ const path = resolve7(process.cwd(), out);
20100
+ const partial = `${path}.partial`;
20101
+ const fd = openSync(partial, "w", 384);
20102
+ let tenantId = "";
20103
+ try {
20104
+ const r = await run(async (cookie, id) => {
20105
+ tenantId = id;
20106
+ writeFileSync6(fd, credentialsHeader(slug, id) + "\n");
20107
+ return exportCredentialsViaSession(dash, cookie, id, (users) => {
20108
+ if (users.length) writeFileSync6(fd, users.map(credentialLine).join("\n") + "\n");
20109
+ });
20110
+ });
20111
+ closeSync(fd);
20112
+ chmodSync2(partial, 384);
20113
+ renameSync(partial, path);
20114
+ console.log(`exported ${r.users} password hash(es) of '${slug}' (${tenantId}) \u2192 ${out} (mode 0600)`);
20115
+ } catch (e) {
20116
+ try {
20117
+ closeSync(fd);
20118
+ } catch {
20119
+ }
20120
+ let removed = true;
20121
+ try {
20122
+ rmSync2(partial, { force: true });
20123
+ } catch {
20124
+ removed = false;
20125
+ }
20126
+ fail(`${e.message} \u2014 nothing was written to ${out}` + (removed ? " (the partial file was deleted)" : ` \u2014 DELETE ${out}.partial yourself: it holds the hashes read before the failure`));
20127
+ }
20128
+ }
20129
+ async function exportToPostgres() {
20130
+ const dsn = flag("to-postgres");
20131
+ if (!dsn) fail("usage: vxil export --to-postgres <postgres://\u2026> [--pg-schema <name>] [--urls [--pace <s>]] [--include-password-hashes]");
20132
+ const schema = flag("pg-schema") ?? "vxil";
20133
+ if (!/^[a-z_][a-z0-9_]{0,62}$/.test(schema)) fail(`--pg-schema '${schema}' must be a lower-case SQL identifier`);
20134
+ const urls = hasFlag("urls");
20135
+ const pace = parsePace(flag("pace"));
20136
+ if ("err" in pace) fail(pace.err);
20137
+ const withHashes = hasFlag("include-password-hashes");
20138
+ const dir = mkdtempSync(join4(tmpdir(), "vxil-export-pg-"));
20139
+ chmodSync2(dir, 448);
20140
+ process.once("exit", () => {
20141
+ try {
20142
+ rmSync2(dir, { recursive: true, force: true });
20143
+ } catch {
20144
+ }
20145
+ });
20146
+ let db = null;
20147
+ try {
20148
+ const complete = { users: false, cms: false, files: false };
20149
+ const paths = { users: join4(dir, "users.ndjson"), cms: join4(dir, "cms.ndjson"), files: join4(dir, "files.ndjson") };
20150
+ let worst = 0;
20151
+ for (const f of ["users", "cms", "files"]) {
20152
+ process.exitCode = 0;
20153
+ await exportNdjson(f, { urls: urls && f === "files", paceMs: pace.ms, out: paths[f] });
20154
+ const code = Number(process.exitCode ?? 0);
20155
+ complete[f] = code === 0;
20156
+ worst = Math.max(worst, code);
20157
+ }
20158
+ process.exitCode = worst;
20159
+ let credentials;
20160
+ if (withHashes) {
20161
+ credentials = join4(dir, "credentials.ndjson");
20162
+ await exportPasswordHashes("auth", credentials);
20163
+ }
20164
+ db = openTargetDatabase(dsn);
20165
+ console.error(`vxil export \u2192 your database ${redactSourceUrl(dsn)} (schema "${schema}")`);
20166
+ const report = await loadIntoPostgres(db, schema, {
20167
+ users: paths.users,
20168
+ cms: paths.cms,
20169
+ files: paths.files,
20170
+ ...credentials ? { credentials } : {},
20171
+ complete
20172
+ }, (m) => console.log(m));
20173
+ if (report.skipped.length) console.error(`vxil: ${report.skipped.length} row(s) not loaded:
20174
+ ${report.skipped.slice(0, 20).join("\n ")}`);
20175
+ if (worst !== 0) console.error(`vxil: an export finished incomplete (exit ${worst}) \u2014 its tables were loaded but nothing was removed from them; re-run to complete`);
20176
+ } catch (e) {
20177
+ fail(`export to your database: ${e.message}`);
20178
+ } finally {
20179
+ await db?.end({ timeout: 5 }).catch(() => void 0);
20180
+ rmSync2(dir, { recursive: true, force: true });
20181
+ }
19158
20182
  }
19159
20183
  async function completeJsonObjects(bundle, t, paceMs, firstPageAt) {
19160
20184
  const cut = objectsCut(bundle);
@@ -19191,6 +20215,9 @@ async function runApply(apply, sel = "prod", opts = {}) {
19191
20215
  const { api, target } = requireApi(sel, apply ? { write: "push" } : gate ? { exitCode: 2 } : {});
19192
20216
  if (!apply) console.error(targetBanner(target, gate ? "diff" : "plan"));
19193
20217
  const report = { drift: 0, ignored: 0, errors: 0 };
20218
+ const pushProblems = { hard: [], strict: [] };
20219
+ const strictPush = apply && hasFlag("strict");
20220
+ const allowDestructive = hasFlag("allow-destructive");
19194
20221
  const compared = newComparedCounts({ apiState: !!opts.apiState });
19195
20222
  const ignore = apply ? [] : loadDriftIgnore();
19196
20223
  const ignoredKeys = [];
@@ -19302,6 +20329,7 @@ ${r.feature} (remote v${r.version}):`);
19302
20329
  ${r.feature}.${r.datum} (declared in config):`);
19303
20330
  if (r.remoteUnavailable) {
19304
20331
  notCompared(r.remoteUnavailable, apply ? "the server did not converge it \u2014 re-run `vxil push`" : `needs ${r.datum === "policies" ? "ratelimits:read" : r.datum === "subscriptions" ? "webhooks:read" : "ai:read"} or admin`);
20332
+ if (apply) pushProblems.strict.push(`${r.feature}.${r.datum} was not converged (unreadable)`);
19305
20333
  continue;
19306
20334
  }
19307
20335
  console.log(formatDeclaredApiState(r, { strict: strictApiState }));
@@ -19309,11 +20337,13 @@ ${r.feature}.${r.datum} (declared in config):`);
19309
20337
  if (r.refused) {
19310
20338
  if (gate) report.errors++;
19311
20339
  else console.error(`vxil: warning \u2014 ${r.feature}.${r.datum} not converged: ${r.refused}`);
20340
+ if (apply) pushProblems.strict.push(`${r.feature}.${r.datum} refused: ${r.refused}`);
19312
20341
  continue;
19313
20342
  }
19314
20343
  if (r.deferred) {
19315
20344
  console.error(`vxil: warning \u2014 ${r.feature}.${r.datum} not converged yet: ${r.deferred}`);
19316
20345
  if (gate) report.errors++;
20346
+ if (apply) pushProblems.strict.push(`${r.feature}.${r.datum} deferred: ${r.deferred}`);
19317
20347
  continue;
19318
20348
  }
19319
20349
  if (apply && (r.summary.created.length || r.summary.updated.length || r.summary.recreated.length || r.summary.deleted.length)) {
@@ -19324,6 +20354,7 @@ ${r.feature}.${r.datum} (declared in config):`);
19324
20354
  if (row2.kind === "ambiguous" || row2.kind === "failed") {
19325
20355
  if (gate) report.errors++;
19326
20356
  else if (apply && row2.kind === "failed") console.error(`vxil: warning \u2014 ${key} was not converged`);
20357
+ if (apply) pushProblems.strict.push(`${key} ${row2.kind === "failed" ? "was not converged" : "is ambiguous"}`);
19327
20358
  continue;
19328
20359
  }
19329
20360
  if (row2.kind === "undeclared" && !strictApiState) {
@@ -19341,8 +20372,29 @@ ${r.feature}.${r.datum} (declared in config):`);
19341
20372
  });
19342
20373
  await runSection("functions", async () => {
19343
20374
  if (!cfg.functions || !Object.keys(cfg.functions).length) {
19344
- if (cfg.features.functions) {
19345
- console.log("\nfunctions: features.functions is declared but no functions/ are defined to deploy \u2014 the feature enables per-function on deploy (no feature-level config namespace).");
20375
+ const ownsFunctions = !!cfg.features.functions || !skipFunctions && await backendListsFunctions(api);
20376
+ if (ownsFunctions) {
20377
+ if (cfg.features.functions) {
20378
+ console.log("\nfunctions: features.functions is declared but no functions/ are defined to deploy \u2014 the feature enables per-function on deploy (no feature-level config namespace).");
20379
+ } else {
20380
+ console.log("\nfunctions: vxil.config declares none, but the functions feature is enabled on the target:");
20381
+ }
20382
+ if (!skipFunctions) {
20383
+ const r2 = await planFunctions({ api, functions: {}, cwd, apply, remoteOnly: allowDestructive ? "delete" : "report" });
20384
+ if (r2.remoteUnavailable) {
20385
+ notCompared(r2.remoteUnavailable, "needs functions:read, features:read or admin");
20386
+ return;
20387
+ }
20388
+ if (r2.changes.length) console.log(formatFnChanges(r2.changes));
20389
+ for (const c of r2.changes) count(`function:${c.name}`);
20390
+ compared.functions += r2.changes.length;
20391
+ if (apply && r2.hookReconcile && r2.hookReconcile.failed > 0) {
20392
+ pushProblems.hard.push(`trigger wiring: ${r2.hookReconcile.failed} subscription(s) failed to reconcile \u2014 ${hookReconcileRemedy(void 0)}`);
20393
+ }
20394
+ if (apply && r2.remoteOnlyKept?.length) {
20395
+ pushProblems.strict.push(`deployed but not declared (kept): ${r2.remoteOnlyKept.join(", ")} \u2014 \`vxil push --allow-destructive\` deletes them`);
20396
+ }
20397
+ }
19346
20398
  }
19347
20399
  return;
19348
20400
  }
@@ -19351,7 +20403,7 @@ ${r.feature}.${r.datum} (declared in config):`);
19351
20403
  functions: skipped (--skip-functions) \u2014 ${declaredFns.length} declared function(s) not ${apply ? "deployed" : "compared"}`);
19352
20404
  return;
19353
20405
  }
19354
- const r = await planFunctions({ api, functions: cfg.functions, cwd, apply });
20406
+ const r = await planFunctions({ api, functions: cfg.functions, cwd, apply, remoteOnly: allowDestructive ? "delete" : "report" });
19355
20407
  console.log("\nfunctions:");
19356
20408
  if (r.remoteUnavailable) {
19357
20409
  notCompared(r.remoteUnavailable, "needs functions:read, features:read or admin");
@@ -19360,6 +20412,7 @@ functions: skipped (--skip-functions) \u2014 ${declaredFns.length} declared func
19360
20412
  console.log(formatFnChanges(r.changes));
19361
20413
  if (apply && r.applied) console.log(` \u2713 deployed ${r.applied} function(s)`);
19362
20414
  for (const w of r.warnings ?? []) {
20415
+ if (r.hookReconcile?.warning && w.message === r.hookReconcile.warning) continue;
19363
20416
  console.error(` ! ${w.fn}: ${w.message}`);
19364
20417
  }
19365
20418
  if (apply && r.cmsHookSubscriptions && (r.cmsHookSubscriptions.created || r.cmsHookSubscriptions.deleted)) {
@@ -19369,8 +20422,36 @@ functions: skipped (--skip-functions) \u2014 ${declaredFns.length} declared func
19369
20422
  console.log(` \u2713 webhook-trigger subscription reconciled (+${r.webhookSubscriptions.created} / -${r.webhookSubscriptions.deleted})`);
19370
20423
  }
19371
20424
  for (const c of r.changes) if (c.kind !== "unchanged") count(`function:${c.name}`);
19372
- compared.functions += Object.keys(cfg.functions).length;
20425
+ compared.functions += Object.keys(cfg.functions).length + r.changes.filter((c) => c.kind === "remote-only" || c.kind === "delete").length;
20426
+ if (apply && r.hookReconcile && r.hookReconcile.failed > 0) {
20427
+ const why = r.hookReconcile.warning ?? `${r.hookReconcile.failed} trigger subscription(s) could not be written`;
20428
+ console.error(` ! trigger wiring: ${why}`);
20429
+ pushProblems.hard.push(`trigger wiring: ${r.hookReconcile.failed} subscription(s) failed to reconcile \u2014 a function may be wired to nothing; ${hookReconcileRemedy(declaredFns[0])}`);
20430
+ }
20431
+ if (apply && r.remoteOnlyKept?.length) {
20432
+ pushProblems.strict.push(`deployed but not declared (kept): ${r.remoteOnlyKept.join(", ")} \u2014 \`vxil push --allow-destructive\` deletes them`);
20433
+ }
19373
20434
  });
20435
+ if (!apply) {
20436
+ await runSection("api-majors", async () => {
20437
+ const typesPath = resolve7(cwd, "vxil.types.ts");
20438
+ const pinned = existsSync8(typesPath) ? parseApiVersionsBlock(readFileSync8(typesPath, "utf8")) : null;
20439
+ if (!pinned) return;
20440
+ const fres = await api("GET", "/v1/features");
20441
+ const live = fres.status === 200 ? fres.body.data?.api_versions : void 0;
20442
+ if (!live) {
20443
+ if (fres.status !== 200) notCompared({ route: "GET /v1/features", status: fres.status }, "needs any valid key");
20444
+ return;
20445
+ }
20446
+ const rows = apiMajorDrift(pinned, live);
20447
+ console.log("\napi majors (vxil.types.ts API_VERSIONS vs the backend):");
20448
+ if (!rows.length) console.log(` (in sync \u2014 ${Object.keys(pinned).length} feature(s) pinned)`);
20449
+ for (const r of rows) {
20450
+ console.log(` ~ api-major:${r.feature} pinned [${r.pinned.join(", ")}] \u2192 backend [${r.live.join(", ")}] \u2014 read the changelog, then \`vxil gen\` online to re-pin`);
20451
+ count(`api-major:${r.feature}`);
20452
+ }
20453
+ });
20454
+ }
19374
20455
  const strictSecrets = hasFlag("strict-secrets");
19375
20456
  const informationalKeys = [];
19376
20457
  if (!apply && (gate || explain)) {
@@ -19468,6 +20549,21 @@ vxil diff \u2192 ${report.drift} drift item(s), ${report.ignored} ignored, ${rep
19468
20549
  }
19469
20550
  if (!apply) console.log("\n(run `vxil push` to apply)");
19470
20551
  if (apply && !hasFlag("no-gen")) await runGen(sel);
20552
+ if (apply) {
20553
+ const exitWith = pushExitCode(pushProblems, strictPush);
20554
+ for (const line of exitWith.lines) console.error(`vxil: ${line}`);
20555
+ return exitWith.code;
20556
+ }
20557
+ return 0;
20558
+ }
20559
+ async function backendListsFunctions(api) {
20560
+ try {
20561
+ const res = await api("GET", "/v1/features");
20562
+ const list = res.status === 200 ? res.body.data?.features : void 0;
20563
+ return Array.isArray(list) && list.includes("functions");
20564
+ } catch {
20565
+ return false;
20566
+ }
19471
20567
  }
19472
20568
  async function runServerApply(sel) {
19473
20569
  const { api, target } = requireApi(sel, { write: "push --server" });
@@ -19480,6 +20576,11 @@ apply ${done2.apply_id}: ${done2.status}`);
19480
20576
  if (done2.status === "running") console.log(`resume with: vxil push --server --resume ${done2.apply_id}`);
19481
20577
  if (done2.status !== "succeeded" && done2.status !== "running") process.exit(1);
19482
20578
  if (done2.status === "succeeded" && !hasFlag("no-gen")) await runGen(sel);
20579
+ const resumed = { hard: [], strict: [] };
20580
+ applyHookProblems(done2, resumed);
20581
+ const exitResumed = pushExitCode(resumed, hasFlag("strict"));
20582
+ for (const line of exitResumed.lines) console.error(`vxil: ${line}`);
20583
+ if (exitResumed.code !== 0) process.exit(exitResumed.code);
19483
20584
  return;
19484
20585
  }
19485
20586
  const loaded = await loadVxilConfig();
@@ -19539,13 +20640,47 @@ apply ${done.apply_id}: ${done.status}`);
19539
20640
  }
19540
20641
  const standing = done.steps.filter((st) => st.kind === "api-state" && st.status === "failed");
19541
20642
  const onlyApiStateFailed = done.steps.every((st) => st.status !== "failed" || st.kind === "api-state");
20643
+ const problems = { hard: [], strict: [] };
19542
20644
  if (done.status === "partial" && standing.length && onlyApiStateFailed && done.failed_step?.kind === "api-state" && done.steps.every((st) => st.status !== "rolled_back")) {
19543
- for (const st of standing) console.error(`vxil: warning \u2014 ${st.target} not converged: ${st.error ?? ""} (the config stands; fix the cause and re-push)`);
20645
+ for (const st of standing) {
20646
+ console.error(`vxil: warning \u2014 ${st.target} not converged: ${st.error ?? ""} (the config stands; fix the cause and re-push)`);
20647
+ problems.strict.push(`${st.target} not converged: ${st.error ?? ""}`);
20648
+ }
19544
20649
  } else if (done.status !== "succeeded") {
19545
20650
  const f = done.failed_step;
19546
20651
  fail(`apply ${done.status}${f ? ` \u2014 step ${f.seq} (${f.kind} ${f.target}): ${f.error}` : ""} (config/functions were rolled back to their pre-apply versions; cms schema additions were kept)`);
19547
20652
  }
20653
+ applyHookProblems(done, problems);
20654
+ if (!hasFlag("skip-functions") && (fnNames.length || cfg.features.functions || await backendListsFunctions(api))) {
20655
+ const list = await api("GET", "/v1/functions");
20656
+ if (list.status !== 200) {
20657
+ const code = list.body.error?.code;
20658
+ console.error(`vxil: warning \u2014 GET /v1/functions \u2192 ${list.status}${code ? ` ${code}` : ""}: deployed functions the config no longer declares were NOT checked`);
20659
+ problems.strict.push(`functions list unreadable (${list.status}${code ? ` ${code}` : ""}) \u2014 remote-only functions not checked`);
20660
+ } else {
20661
+ const deployed = (list.body.data?.functions ?? []).map((f) => f.name);
20662
+ for (const name of deployed.filter((n) => !fnNames.includes(n)).sort()) {
20663
+ if (hasFlag("allow-destructive")) {
20664
+ const del = await deleteRemoteFunction(api, name);
20665
+ console.log(` - ${name} [deployed, not in vxil.config] \u2014 ${del.gone ? "already gone" : "deleted (--allow-destructive)"}`);
20666
+ if (del.hook && del.hook.failed > 0) problems.hard.push(`trigger wiring: ${del.hook.failed} subscription(s) failed to reconcile after deleting ${name} \u2014 ${hookReconcileRemedy(fnNames[0])}`);
20667
+ } else {
20668
+ console.log(` - ${name} [deployed, not in vxil.config] \u2014 kept; \`vxil push --server --allow-destructive\` deletes it`);
20669
+ problems.strict.push(`deployed but not declared (kept): ${name}`);
20670
+ }
20671
+ }
20672
+ }
20673
+ }
19548
20674
  if (!hasFlag("no-gen")) await runGen(sel);
20675
+ const exitWith = pushExitCode(problems, hasFlag("strict"));
20676
+ for (const line of exitWith.lines) console.error(`vxil: ${line}`);
20677
+ if (exitWith.code !== 0) process.exit(exitWith.code);
20678
+ }
20679
+ function applyHookProblems(done, problems) {
20680
+ const hook = hookReconcileOf(done);
20681
+ if (!hook || hook.failed === 0) return;
20682
+ console.error(` ! trigger wiring: ${hook.warning ?? `${hook.failed} trigger subscription(s) could not be written`}`);
20683
+ problems.hard.push(`trigger wiring: ${hook.failed} subscription(s) failed to reconcile \u2014 a function may be wired to nothing; re-run \`vxil push --server\` (every executed apply re-runs the reconcile)`);
19549
20684
  }
19550
20685
  async function runSeed(sel, opts = {}) {
19551
20686
  const { api } = requireApi(sel, { write: "seed" });
@@ -19583,6 +20718,8 @@ async function runGen(sel = "prod", opts = {}) {
19583
20718
  let apiVersions;
19584
20719
  if (offline) {
19585
20720
  ({ env, features, collections, functions } = genInputFromConfig(await loadVxilConfig()));
20721
+ const committed = resolve7(process.cwd(), flag("out") ?? "vxil.types.ts");
20722
+ if (existsSync8(committed)) apiVersions = parseApiVersionsBlock(readFileSync8(committed, "utf8")) ?? void 0;
19586
20723
  } else {
19587
20724
  const { api, slug } = requireApi(sel);
19588
20725
  tenant = slug;
@@ -19643,7 +20780,9 @@ async function runGen(sel = "prod", opts = {}) {
19643
20780
  }
19644
20781
  async function runWatch(sel) {
19645
20782
  const cwd = process.cwd();
19646
- await promotionGate(resolveTargetOrFail(sel), false);
20783
+ const watchTarget = resolveTargetOrFail(sel);
20784
+ if (watchTarget.apiKey) await assertKeyTenant(watchTarget, makeApi({ apiKey: watchTarget.apiKey, baseUrl: watchTarget.baseUrl }));
20785
+ await promotionGate(watchTarget, false);
19647
20786
  console.log("vxil dev --watch: applying on save (Ctrl-C to stop)\u2026");
19648
20787
  const apply = async () => {
19649
20788
  try {
@@ -19734,6 +20873,7 @@ async function runDoctor() {
19734
20873
  if (t?.apiKey) {
19735
20874
  const api = makeApi({ apiKey: t.apiKey, baseUrl: t.baseUrl });
19736
20875
  const fres = await api("GET", "/v1/features");
20876
+ if (knownTenantId(t.tenantId)) checks.push(keyTenantCheck(judgeKeyTenant(t.tenantId, fres)));
19737
20877
  if (fres.status === 200) {
19738
20878
  const live = new Set(fres.body.data?.features ?? []);
19739
20879
  checks.push({ name: "edge reachable", ok: true, detail: `${live.size} features enabled live` });
@@ -19790,7 +20930,10 @@ async function runDoctor() {
19790
20930
  const local = /* @__PURE__ */ new Map();
19791
20931
  for (const [n, def] of Object.entries(cfg.functions)) {
19792
20932
  try {
19793
- local.set(n, sourceSha12((await bundleFunction(resolve7(process.cwd(), def.entry), { projectRoot: process.cwd() })).source));
20933
+ local.set(n, sourceSha12(
20934
+ (await bundleFunction(resolve7(process.cwd(), def.entry), { projectRoot: process.cwd() })).source,
20935
+ recordedFunctionsRuntime(lres.body.data?.runtime ?? FUNCTIONS_RUNTIME)
20936
+ ));
19794
20937
  } catch (e) {
19795
20938
  checks.push({ name: `function '${n}' bundle`, ok: false, detail: e.message.split("\n")[0] ?? "bundle failed" });
19796
20939
  }
@@ -19920,6 +21063,14 @@ function listTemplates() {
19920
21063
  }
19921
21064
  console.log("\nEach template is a typed vxil.config.ts you clone and OWN \u2014 no platform lock over the shape.");
19922
21065
  }
21066
+ {
21067
+ const help = helpRequest(cmd, rest);
21068
+ if (help && "print" in help) {
21069
+ console.log(help.print);
21070
+ process.exit(0);
21071
+ }
21072
+ if (help && "delegate" in help) normalizeHelpArgv(rest);
21073
+ }
19923
21074
  try {
19924
21075
  switch (cmd) {
19925
21076
  case "--version":
@@ -19984,7 +21135,8 @@ try {
19984
21135
  if (d.expires_at) console.log(` dev tenant \u2014 expires ${d.expires_at} (the nightly reaper tears it down; extend by recreating)`);
19985
21136
  if (!hasFlag("no-push")) {
19986
21137
  console.log("\napplying your config (cms schema \u2192 config \u2192 functions) + generating types\u2026");
19987
- await runApply(true);
21138
+ const pushCode = await runApply(true);
21139
+ if (pushCode !== 0) process.exitCode = pushCode;
19988
21140
  }
19989
21141
  let firstCollection = "your-collection";
19990
21142
  try {
@@ -20458,8 +21610,8 @@ try {
20458
21610
  }
20459
21611
  case "projects": {
20460
21612
  const [sub = "list", arg] = positional(0);
20461
- const PROJECTS_USAGE = "usage: vxil projects [list] [--json] \xB7 projects create <slug> [--name <display>] [--json] \xB7 projects rm <slug> [--yes] [--json]";
20462
- if (!["list", "create", "rm"].includes(sub)) fail(PROJECTS_USAGE);
21613
+ const PROJECTS_USAGE = "usage: vxil projects [list] [--json] \xB7 projects create <slug> [--name <display>] [--workload production|staging|development] [--json] \xB7 projects workload <slug> production|staging|development [--json] \xB7 projects rm <slug> [--yes] [--json]";
21614
+ if (!["list", "create", "rm", "workload"].includes(sub)) fail(PROJECTS_USAGE);
20463
21615
  const base2 = accountDashBase();
20464
21616
  const dash = makeDash(base2);
20465
21617
  const host = dashboardOrigin(base2);
@@ -20471,15 +21623,32 @@ try {
20471
21623
  break;
20472
21624
  }
20473
21625
  if (sub === "create") {
20474
- const p = parseProjectCreate(arg, flag("name"));
21626
+ const p = parseProjectCreate(arg, flag("name"), flag("workload"));
20475
21627
  if ("error" in p) fail(p.error);
20476
21628
  console.error(`vxil projects create ${p.body.slug} \u2192 ${host}`);
20477
21629
  const { project } = await withDashSession(dash, base2, (c) => createProjectViaSession(dash, c, p.body));
20478
21630
  if (jsonOut) console.log(JSON.stringify(project, null, 2));
20479
- else console.log(`\u2713 created '${project.slug}' (${project.display_name}, ${project.tier}) \u2014 no key and no features yet`);
21631
+ else console.log(`\u2713 created '${project.slug}' (${project.display_name}, ${project.tier}${project.workload ? `, ${project.workload}` : ""}) \u2014 no key and no features yet`);
20480
21632
  console.error(`next: vxil link ${project.slug} --mint && vxil push (this repo's binding is unchanged)`);
20481
21633
  break;
20482
21634
  }
21635
+ if (sub === "workload") {
21636
+ if (!arg) fail(PROJECTS_USAGE);
21637
+ const w = parseWorkloadArg(positional(0)[2]);
21638
+ if ("error" in w) fail(w.error);
21639
+ const { tenants: tenants2 } = await withDashSession(dash, base2, (c) => listProjectsViaSession(dash, c));
21640
+ const target = tenants2.find((x) => x.slug === arg);
21641
+ if (!target) fail(`no project '${arg}' on this account \u2014 \`vxil projects list\``);
21642
+ console.error(`vxil projects workload ${arg} ${w.workload} \u2192 ${host}`);
21643
+ const { change } = await withDashSession(dash, base2, (c) => setProjectWorkloadViaSession(dash, c, target.id, w.workload));
21644
+ if (jsonOut) console.log(JSON.stringify({ slug: arg, ...change }, null, 2));
21645
+ else {
21646
+ const out = renderWorkloadChange(arg, change);
21647
+ console.log(out.stdout);
21648
+ for (const l of out.stderr) console.error(l);
21649
+ }
21650
+ break;
21651
+ }
20483
21652
  if (!arg) fail(PROJECTS_USAGE);
20484
21653
  const { tenants } = await withDashSession(dash, base2, (c) => listProjectsViaSession(dash, c));
20485
21654
  const victim = tenants.find((x) => x.slug === arg);
@@ -20710,12 +21879,15 @@ ${formatSummary(state)}`);
20710
21879
  case "push": {
20711
21880
  if (hasFlag("prod") && (hasFlag("dev") || flag("target") !== void 0)) fail("--prod cannot be combined with --dev / --target \u2014 pick one target");
20712
21881
  const sel = selector();
20713
- await promotionGate(resolveTargetOrFail(sel), hasFlag("prod"));
21882
+ const pushTarget = resolveTargetOrFail(sel);
21883
+ if (pushTarget.apiKey) await assertKeyTenant(pushTarget, makeApi({ apiKey: pushTarget.apiKey, baseUrl: pushTarget.baseUrl }));
21884
+ await promotionGate(pushTarget, hasFlag("prod"));
20714
21885
  if (hasFlag("server") || flag("resume") !== void 0) {
20715
21886
  await runServerApply(sel);
20716
21887
  break;
20717
21888
  }
20718
- await runApply(true, sel);
21889
+ const pushCode = await runApply(true, sel);
21890
+ if (pushCode !== 0) process.exit(pushCode);
20719
21891
  break;
20720
21892
  }
20721
21893
  case "gen":
@@ -20851,7 +22023,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
20851
22023
  case "env": {
20852
22024
  const [sub] = positional(0);
20853
22025
  if (sub !== "pull") fail("usage: vxil env pull [--dev | --target <name>] [--file <path=.env.local>] [--print] [--no-gitignore]");
20854
- runEnvPull(selector());
22026
+ await runEnvPull(selector());
20855
22027
  break;
20856
22028
  }
20857
22029
  case "secrets": {
@@ -20925,8 +22097,16 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
20925
22097
  break;
20926
22098
  }
20927
22099
  case "export": {
22100
+ if (flag("to-postgres") !== void 0) {
22101
+ await exportToPostgres();
22102
+ break;
22103
+ }
20928
22104
  const feature = positional(0)[0];
20929
- if (!feature) fail("usage: vxil export <feature> [--out <file>] [--ndjson] [--urls [--pace <seconds>]]");
22105
+ if (!feature) fail("usage: vxil export <feature> [--out <file>] [--ndjson] [--urls [--pace <seconds>]] \xB7 vxil export auth --include-password-hashes --out <file> (owner only)");
22106
+ if (hasFlag("include-password-hashes")) {
22107
+ await exportPasswordHashes(feature);
22108
+ break;
22109
+ }
20930
22110
  const urls = hasFlag("urls");
20931
22111
  const pace = parsePace(flag("pace"));
20932
22112
  if ("err" in pace) fail(pace.err);
@@ -20957,7 +22137,8 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
20957
22137
  writeFileSync6(resolve7(process.cwd(), out), json2);
20958
22138
  console.log(`exported ${feature} \u2192 ${out}`);
20959
22139
  } else console.log(json2);
20960
- for (const note of truncationNotes(bundle)) {
22140
+ const notes = truncationNotes(bundle);
22141
+ for (const note of notes) {
20961
22142
  console.error(`vxil: note \u2014 ${note} (the JSON bundle is capped; \`--ndjson\` resumes past the cap)`);
20962
22143
  }
20963
22144
  if (urls) {
@@ -20965,9 +22146,14 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
20965
22146
  if (Array.isArray(downloads)) reportDownloads(tallyDownloads(newDownloadTally(), downloads), started, false);
20966
22147
  if (unresumable) {
20967
22148
  console.error("vxil: the objects table was cut at the 100-row URL page and the server gave no resume cursor \u2014 this bundle holds only the first 100 objects. Re-run with --ndjson (it resumes page by page).");
20968
- process.exitCode = 1;
22149
+ process.exitCode = worseExportExit(process.exitCode, EXPORT_EXIT_INCOMPLETE);
20969
22150
  }
20970
22151
  }
22152
+ if (notes.length) {
22153
+ const cut = notes.map((n) => n.split(":")[0]);
22154
+ console.error(incompleteTablesMessage(feature, cut, true));
22155
+ process.exitCode = worseExportExit(process.exitCode, EXPORT_EXIT_INCOMPLETE);
22156
+ }
20971
22157
  break;
20972
22158
  }
20973
22159
  case "import": {
@@ -20994,20 +22180,33 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
20994
22180
  console.log(`scaffolded functions/${name}.ts \u2014 declare it in vxil.config.ts \`functions\` and \`vxil push\``);
20995
22181
  } else if (sub === "list") {
20996
22182
  const { api } = requireApi(selector());
20997
- printEnvelope(await api("GET", "/v1/functions"));
22183
+ const listRes = await api("GET", "/v1/functions");
22184
+ printEnvelope(listRes);
22185
+ if (listRes.status === 200 && listRes.body?.data?.enabled === false) {
22186
+ console.error("vxil: these functions are PAUSED for the whole project (every invoke answers 404) \u2014 a Free project on the production workload, or a plan downgrade. `vxil projects workload <slug> staging` or an upgrade resumes them.");
22187
+ }
20998
22188
  } else if (sub === "delete") {
20999
22189
  if (!name) fail("usage: vxil functions delete <name>");
21000
22190
  const { api } = requireApi(selector(), { write: `functions delete ${name}` });
21001
- printEnvelope(await api("DELETE", `/v1/functions/${encodeURIComponent(name)}`));
22191
+ const delRes = await api("DELETE", `/v1/functions/${encodeURIComponent(name)}`);
22192
+ const delHook = delRes.status === 200 ? hookReconcileOf(delRes.body.data) : null;
22193
+ if (delHook && delHook.failed > 0) {
22194
+ console.error(`vxil: ${delHook.warning ?? `${delHook.failed} trigger subscription(s) failed to reconcile`} \u2014 ${hookReconcileRemedy(void 0)}`);
22195
+ process.exitCode = 1;
22196
+ }
22197
+ printEnvelope(delRes);
21002
22198
  } else if (sub === "deploy") {
21003
22199
  const { api } = requireApi(selector(), { write: `functions deploy${name ? ` ${name}` : ""}` });
21004
22200
  const cfg = await loadVxilConfig();
21005
22201
  const fns = cfg.functions ?? {};
21006
22202
  const subset = name ? { [name]: fns[name] } : fns;
21007
22203
  if (name && !fns[name]) fail(`function '${name}' not declared in vxil.config`);
21008
- const r = await planFunctions({ api, functions: subset, cwd: process.cwd(), apply: true });
22204
+ const r = await planFunctions({ api, functions: subset, cwd: process.cwd(), apply: true, force: hasFlag("force") });
21009
22205
  console.log(formatFnChanges(r.changes));
21010
22206
  console.log(`\u2713 deployed ${r.applied} function(s)`);
22207
+ if (r.hookReconcile && r.hookReconcile.failed > 0) {
22208
+ fail(`${r.hookReconcile.warning ?? `${r.hookReconcile.failed} trigger subscription(s) failed to reconcile \u2014 a function may be wired to nothing`}; ${hookReconcileRemedy(name ?? Object.keys(subset)[0])}`);
22209
+ }
21011
22210
  } else if (sub === "logs") {
21012
22211
  if (!name) fail("usage: vxil functions logs <name> [--since <iso>] [--tail]");
21013
22212
  const { api } = requireApi(selector());
@@ -21156,7 +22355,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
21156
22355
  else console.log(`\u2713 soft-deleted ${done.deleted} item(s) from ${where}${done.cascaded ? ` (${done.cascaded} cascaded)` : ""}${done.set_null ? ` (${done.set_null} relation(s) set null)` : ""} \u2014 purged after 30 days${done.truncated ? "; stopped at the page cap, re-run to continue" : ""}`);
21157
22356
  break;
21158
22357
  }
21159
- const { api } = requireApi(selector());
22358
+ const { api } = requireApi(selector(), sub === "reindex" ? { write: `cms reindex ${positional(1)[0] ?? ""}`.trim() } : {});
21160
22359
  if (sub === "status") {
21161
22360
  printEnvelope(await api("GET", "/v1/cms/collections"));
21162
22361
  } else if (sub === "pull") {
@@ -21298,7 +22497,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
21298
22497
  keys perms <key_id> [--allow <a,b> | --clear-allow] [--deny <c> | --clear-deny] \u2014 a key's MCP tool permissions (admin+)
21299
22498
  account [show] \xB7 account set [--locale <code>] [--display-name <n>] \xB7 account password \xB7 logout \xB7 invites [list] \xB7 invites generate [--count <n>] \xB7 invites accept <token|url>
21300
22499
  members [list] \xB7 members invite <email> --role <r> \xB7 members add <email> --role <r> \xB7 members rm <user_id|email> [--yes] \xB7 members uninvite <email|id> [--yes] \u2014 the bound project's team (session)
21301
- projects [list] \xB7 projects create <slug> [--name <n>] \xB7 projects rm <slug> [--yes] \u2014 the account's projects (rm: owner-only, typed slug, irreversible)
22500
+ projects [list] \xB7 projects create <slug> [--name <n>] [--workload <w>] \xB7 projects workload <slug> production|staging|development \xB7 projects rm <slug> [--yes] \u2014 the account's projects (workload + rm: owner-only; rm: typed slug, irreversible)
21302
22501
  billing [status] \xB7 billing upgrade --tier <free|developer|team|business> [--yes] \u2014 prints the hosted checkout URL (never opens a browser); --tier free downgrades
21303
22502
  architect "<describe your app>" \u2014 plain English in, a reviewed vxil.config.ts draft out
21304
22503
  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>]
@@ -21320,8 +22519,8 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
21320
22519
  mcp install [--client cursor|claude|vscode] [--project] [--print] [--key <k>] [--scopes a,b] [--name <server>] \u2014 connect your editor's agent to this backend's MCP server
21321
22520
  dev branch [<name>] [--ttl <h=24>] [--no-env] [--print] | --list | --rm <name> [--yes]
21322
22521
  config get \xB7 users add|list|import \xB7 send \xB7 deliveries \xB7 api <METHOD> <path> ['<json>' | --data '<json>']
21323
- global: --json (machine-readable + exit codes)`);
21324
- process.exit(cmd ? 1 : 0);
22522
+ global: --json (machine-readable + exit codes) \xB7 --help / -h on any verb prints its usage (nothing runs)`);
22523
+ process.exit(isGlobalHelp(cmd) ? 0 : 1);
21325
22524
  }
21326
22525
  } catch (e) {
21327
22526
  fail(e.message);
@@ -21361,9 +22560,14 @@ async function runDev(sub) {
21361
22560
  if (!/no dev tenant bound/.test(e.message)) throw e;
21362
22561
  });
21363
22562
  await runDev("up");
21364
- await runApply(true, { kind: "dev" });
22563
+ const pushCode = await runApply(true, { kind: "dev" });
21365
22564
  await runSeed({ kind: "dev" }, { optional: true });
21366
- console.log("\u2713 dev reset complete (torn down \u2192 recreated \u2192 pushed \u2192 seeded)");
22565
+ if (pushCode !== 0) {
22566
+ process.exitCode = pushCode;
22567
+ console.log("\u2713 dev reset complete (torn down \u2192 recreated \u2192 pushed \u2192 seeded) \u2014 the push reported a problem above (exit 1)");
22568
+ } else {
22569
+ console.log("\u2713 dev reset complete (torn down \u2192 recreated \u2192 pushed \u2192 seeded)");
22570
+ }
21367
22571
  } else {
21368
22572
  fail("usage: vxil dev up|down|seed|reset|branch");
21369
22573
  }
@@ -21483,7 +22687,7 @@ async function runDevBranch(dash, base2) {
21483
22687
  console.log(`export VXIL_TENANT_ID=${slot.tenant_id}`);
21484
22688
  }
21485
22689
  }
21486
- if (!hasFlag("no-env") && !hasFlag("print")) runEnvPull({ kind: "dev" });
22690
+ if (!hasFlag("no-env") && !hasFlag("print")) await runEnvPull({ kind: "dev" });
21487
22691
  console.log(" note: re-run `vxil gen` \u2014 generated types drift across branch schemas.");
21488
22692
  }
21489
22693
  function currentGitBranch() {
@@ -21587,10 +22791,11 @@ async function runConfigDraft(sub, feature) {
21587
22791
  if (jsonOut) console.log(JSON.stringify({ feature, duplicated_from: v.version, manifest: d.manifest }, null, 2));
21588
22792
  else console.log(`\u2713 copied ${feature} v${v.version} into the draft \u2014 NOT live; edit it via \`vxil config draft show ${feature} --json > draft.json\` + \`vxil config draft save ${feature} --file draft.json\`, then publish`);
21589
22793
  }
21590
- function runEnvPull(sel) {
22794
+ async function runEnvPull(sel) {
21591
22795
  const t = resolveTargetOrFail(sel);
21592
22796
  if (!t.apiKey) fail(noKeyMessage(t));
21593
22797
  announceTarget(t, "env pull");
22798
+ await assertKeyTenant(t, makeApi({ apiKey: t.apiKey, baseUrl: t.baseUrl }));
21594
22799
  const tenantId = t.tenantId && t.tenantId !== "(unknown)" ? t.tenantId : void 0;
21595
22800
  const updates = envUpdates({
21596
22801
  apiKey: t.apiKey,
@@ -22108,11 +23313,15 @@ function migrateUsage() {
22108
23313
  + migrate/vxil.config.suggested.ts + migrate/RESIDUALS.md
22109
23314
  (credit seeding is opt-in: tables named in migrate/payments.json)
22110
23315
  schema [--apply] create the plan's cms + vector-search collections (default: dry-run diff; additive only)
22111
- data [--execute] [--only users,cms,vectors,files,payments|<table>,\u2026] [--resume]
23316
+ data [--execute] [--only users,credentials,cms,vectors,files,payments|<table>,\u2026] [--resume]
22112
23317
  the ordered backfill users \u2192 cms (FK order) \u2192 vectors \u2192 files \u2192 payments-credits
22113
23318
  (DEFAULT DRY-RUN \u2014 zero writes; resumable via migrate/state.json)
22114
23319
  [--since <iso-time> [--prune]] the incremental pass: re-read, write only what
22115
23320
  changed since then (+ --prune: delete what the source no longer has)
23321
+ live-app migration (supabase): [--with-password-hashes] keep every password (bcrypt,
23322
+ rehashed at each user's first sign-in) \xB7 [--oidc-issuer https://<ref>.supabase.co/auth/v1]
23323
+ link users for the installed-app session hand-over \xB7 [--file-links shared-link] stored
23324
+ public file URLs become stable shared links instead of object ids
22116
23325
  verify reconcile source vs target counts + re-links + restate the residuals (exit 1 on deltas)
22117
23326
  payments --from-provider stripe|paddle|paypal|revenuecat (--customers <file.csv> | --all) [--dry-run]
22118
23327
  backfill SUBSCRIPTIONS through the server sync leg (the project's own provider keys; resumable via
@@ -22207,9 +23416,17 @@ ${migrateUsage()}`);
22207
23416
  if (sub === "data") {
22208
23417
  const only = flag("only")?.split(",").map((s) => s.trim()).filter(Boolean);
22209
23418
  const since = flag("since");
23419
+ const fileLinks = flag("file-links");
23420
+ if (fileLinks !== void 0 && fileLinks !== "shared-link" && fileLinks !== "object_id") {
23421
+ fail(`--file-links must be shared-link or object_id (got '${fileLinks}')`);
23422
+ }
23423
+ const oidcIssuer = flag("oidc-issuer");
22210
23424
  const results = await runMigrateData({ api, adapter, cwd, log }, {
22211
23425
  execute: hasFlag("execute"),
22212
23426
  resume: hasFlag("resume"),
23427
+ ...hasFlag("with-password-hashes") ? { withPasswordHashes: true } : {},
23428
+ ...oidcIssuer !== void 0 ? { oidcIssuer } : {},
23429
+ ...fileLinks !== void 0 ? { fileLinks } : {},
22213
23430
  ...only?.length ? { only } : {},
22214
23431
  ...hasFlag("assume-schema") ? { assumeSchema: true } : {},
22215
23432
  ...since !== void 0 ? { since } : {},