@vxil/cli 0.4.3 → 0.5.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
@@ -1,38 +1,53 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // bin/vxil.ts
4
- import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync5, existsSync as existsSync7, appendFileSync as appendFileSync2, readFileSync as readFileSync7, chmodSync as chmodSync2 } from "node:fs";
4
+ import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync5, existsSync as existsSync8, appendFileSync as appendFileSync2, readFileSync as readFileSync8, chmodSync as chmodSync2 } from "node:fs";
5
5
  import { spawnSync } from "node:child_process";
6
- import { resolve as resolve7, dirname as dirname4 } from "node:path";
6
+ import { resolve as resolve7, dirname as dirname5 } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
8
  import { watch } from "node:fs";
9
9
  import { createServer } from "node:http";
10
- import { homedir as homedir2, hostname } from "node:os";
10
+ import { homedir as homedir3, hostname } from "node:os";
11
11
 
12
12
  // src/lib.ts
13
13
  import { pathToFileURL, fileURLToPath } from "node:url";
14
14
  import { resolve, dirname, basename, join } from "node:path";
15
15
  import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
16
+ var FN_LIMIT_BOUNDS = {
17
+ cpuMs: { min: 5, max: 3e5 },
18
+ timeoutMs: { min: 1e3, max: 12e4 }
19
+ };
20
+ function normalizeFnLimits(raw) {
21
+ if (!raw || typeof raw !== "object") return void 0;
22
+ const out = {};
23
+ for (const k of ["cpuMs", "timeoutMs"]) {
24
+ const v = raw[k];
25
+ if (typeof v !== "number" || !Number.isFinite(v)) continue;
26
+ const b2 = FN_LIMIT_BOUNDS[k];
27
+ out[k] = Math.min(b2.max, Math.max(b2.min, Math.floor(v)));
28
+ }
29
+ return Object.keys(out).length ? out : void 0;
30
+ }
16
31
  function lowerTriggerBindings(trigger) {
17
32
  const t = trigger?.kind ?? "http";
18
- const str = (k) => typeof trigger?.[k] === "string" ? trigger[k] : void 0;
19
- if (t === "cron") return [{ kind: "cron", ...str("schedule") ? { schedule: str("schedule") } : {} }];
20
- if (t === "queue") return [{ kind: "queue", ...str("source") ? { source: str("source") } : {} }];
21
- if (t === "webhook") return [{ kind: "webhook", ...str("source") ? { source: str("source") } : {} }];
33
+ const str2 = (k) => typeof trigger?.[k] === "string" ? trigger[k] : void 0;
34
+ if (t === "cron") return [{ kind: "cron", ...str2("schedule") ? { schedule: str2("schedule") } : {} }];
35
+ if (t === "queue") return [{ kind: "queue", ...str2("source") ? { source: str2("source") } : {} }];
36
+ if (t === "webhook") return [{ kind: "webhook", ...str2("source") ? { source: str2("source") } : {} }];
22
37
  if (t === "cmsHook" || t === "cms-hook") {
23
38
  return [{
24
39
  kind: "cmsHook",
25
- ...str("collection") ? { collection: str("collection") } : {},
26
- ...str("event") ? { event: str("event") } : {}
40
+ ...str2("collection") ? { collection: str2("collection") } : {},
41
+ ...str2("event") ? { event: str2("event") } : {}
27
42
  }];
28
43
  }
29
44
  if (t === "authHook" || t === "auth-hook") {
30
- return [{ kind: "authHook", ...str("event") ? { event: str("event") } : {} }];
45
+ return [{ kind: "authHook", ...str2("event") ? { event: str2("event") } : {} }];
31
46
  }
32
- return [{ kind: "http", ...str("path") ? { path: str("path") } : {} }];
47
+ return [{ kind: "http", ...str2("path") ? { path: str2("path") } : {} }];
33
48
  }
34
49
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
35
- var VXIL_CONFIG_PKG_VERSION = "0.3.1";
50
+ var VXIL_CONFIG_PKG_VERSION = "0.4.0";
36
51
  function ensureScaffoldPackageJson(cwd) {
37
52
  const file = resolve(cwd, "package.json");
38
53
  const spec = `^${VXIL_CONFIG_PKG_VERSION}`;
@@ -253,6 +268,7 @@ function assembleApplyBundle(cfg, fnSources) {
253
268
  const source = fnSources[name];
254
269
  if (source === void 0) throw new Error(`functions: no bundled source for '${name}'`);
255
270
  const bindings = lowerTriggerBindings(def.trigger);
271
+ const limits = normalizeFnLimits(def.limits);
256
272
  return {
257
273
  name,
258
274
  source,
@@ -261,7 +277,8 @@ function assembleApplyBundle(cfg, fnSources) {
261
277
  ...def.signature ? { signature: def.signature } : {},
262
278
  ...def.scopes ? { scopes: def.scopes } : {},
263
279
  ...def.egressAllow ? { egressAllow: def.egressAllow } : {},
264
- ...def.secrets ? { secrets: def.secrets } : {}
280
+ ...def.secrets ? { secrets: def.secrets } : {},
281
+ ...limits ? { limits } : {}
265
282
  };
266
283
  });
267
284
  return {
@@ -319,6 +336,16 @@ async function planOrPush({ api, features, apply }) {
319
336
  }
320
337
  const effective = dry.body.data?.manifest;
321
338
  const cur = await api("GET", `/v1/config/${feature}`);
339
+ const unavailable = readUnavailable(`GET /v1/config/${feature}`, cur);
340
+ if (unavailable) {
341
+ if (apply) {
342
+ throw new Error(
343
+ `config: cannot read ${unavailable.route} (${unavailable.code ?? unavailable.status}${unavailable.message ? ` \u2014 ${unavailable.message}` : ""}) \u2014 refusing to push ${feature} against a remote it could not read`
344
+ );
345
+ }
346
+ results.push({ feature, version: 0, changes: [], applied: null, remoteUnavailable: unavailable });
347
+ continue;
348
+ }
322
349
  const exists = cur.status === 200;
323
350
  const remote = exists ? cur.body.data?.manifest : {};
324
351
  const version = exists ? cur.body.data?.version ?? 0 : 0;
@@ -341,9 +368,188 @@ async function planOrPush({ api, features, apply }) {
341
368
  }
342
369
  return results;
343
370
  }
371
+ function classifyRead(status, code) {
372
+ if (status === 200) return "ok";
373
+ if (status === 404) return "empty";
374
+ if (status === 501 && (code === void 0 || code === "capability_not_enabled")) return "empty";
375
+ return "unavailable";
376
+ }
377
+ function readUnavailable(route, res) {
378
+ const e = res.body?.error ?? {};
379
+ if (classifyRead(res.status, e.code) !== "unavailable") return null;
380
+ return {
381
+ route,
382
+ status: res.status,
383
+ ...e.code ? { code: e.code } : {},
384
+ ...e.message ? { message: e.message } : {}
385
+ };
386
+ }
387
+ function formatNotCompared(u, hint) {
388
+ return ` ! not compared: ${u.route} \u2192 ${u.code ?? u.status}${u.message ? ` \u2014 ${u.message}` : ""}${hint ? ` (${hint})` : ""}`;
389
+ }
390
+
391
+ // src/apiState.ts
392
+ var POLICY_LIST_CAP = 100;
393
+ var CAMPAIGN_LIST_CAP = 200;
394
+ var LEGACY_POLICY_ALGORITHM = "sliding_window";
395
+ var DEFAULT_SUBSCRIPTION_STATE = "active";
396
+ var FN_TRIGGER_TARGET_MARKERS = [
397
+ "/v1/internal/fn/cms-hook/",
398
+ "/v1/internal/fn/auth-hook/",
399
+ "/v1/internal/fn/trigger/"
400
+ ];
401
+ function isFnTriggerSubscription(targetUrl) {
402
+ return FN_TRIGGER_TARGET_MARKERS.some((m) => targetUrl.includes(m));
403
+ }
404
+ function str(v) {
405
+ return typeof v === "string" ? v : void 0;
406
+ }
407
+ function num(v) {
408
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
409
+ }
410
+ function strList(v) {
411
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
412
+ }
413
+ function defined(o) {
414
+ const out = {};
415
+ for (const [k, v] of Object.entries(o)) if (v !== void 0) out[k] = v;
416
+ return out;
417
+ }
418
+ async function fetchApiState(api) {
419
+ const snap = { policies: [], subscriptions: [], campaigns: [], unavailable: [], truncated: [] };
420
+ const read = async (route, take) => {
421
+ let res;
422
+ try {
423
+ res = await api("GET", route);
424
+ } catch (e) {
425
+ snap.unavailable.push({ route: `GET ${route}`, status: 0, code: "network_error", message: e.message.split("\n")[0] ?? "" });
426
+ return;
427
+ }
428
+ const u = readUnavailable(`GET ${route}`, res);
429
+ if (u) {
430
+ snap.unavailable.push(u);
431
+ return;
432
+ }
433
+ if (classifyRead(res.status, res.body.error?.code) !== "ok") return;
434
+ take(res.body.data ?? {});
435
+ };
436
+ await read("/v1/rate-limits/policies", (d) => {
437
+ const rows = Array.isArray(d.policies) ? d.policies : [];
438
+ for (const raw of rows) {
439
+ const p = raw;
440
+ const name = str(p.name);
441
+ if (!name) continue;
442
+ snap.policies.push(defined({
443
+ name,
444
+ limit: num(p.limit),
445
+ window_seconds: num(p.window_seconds),
446
+ behavior: str(p.behavior),
447
+ key_template: str(p.key_template),
448
+ algorithm: str(p.algorithm) ?? LEGACY_POLICY_ALGORITHM
449
+ }));
450
+ }
451
+ if (rows.length >= POLICY_LIST_CAP) snap.truncated.push("policy");
452
+ });
453
+ await read("/v1/webhooks/subscriptions", (d) => {
454
+ for (const raw of Array.isArray(d.subscriptions) ? d.subscriptions : []) {
455
+ const s = raw;
456
+ const url = str(s.target_url);
457
+ if (!url) continue;
458
+ if (isFnTriggerSubscription(url)) continue;
459
+ snap.subscriptions.push({
460
+ target_url: url,
461
+ event_prefixes: strList(s.event_prefixes),
462
+ state: str(s.state) ?? DEFAULT_SUBSCRIPTION_STATE
463
+ });
464
+ }
465
+ });
466
+ await read("/v1/notifications/campaigns", (d) => {
467
+ const rows = Array.isArray(d.campaigns) ? d.campaigns : [];
468
+ for (const raw of rows) {
469
+ const c = raw;
470
+ const name = str(c.name);
471
+ if (name) snap.campaigns.push({ name });
472
+ }
473
+ if (rows.length >= CAMPAIGN_LIST_CAP) snap.truncated.push("campaign");
474
+ });
475
+ return snap;
476
+ }
477
+ function apiStateDriftKey(row2) {
478
+ return `api:${row2.section}:${row2.key}`;
479
+ }
480
+ function sameSet(a, b2) {
481
+ const A = new Set(a);
482
+ const B = new Set(b2);
483
+ return A.size === B.size && [...A].every((x) => B.has(x));
484
+ }
485
+ function diffBy(section, target, against, keyOf, fields) {
486
+ const rows = [];
487
+ const A = new Map(target.map((t) => [keyOf(t), t]));
488
+ const B = new Map(against.map((t) => [keyOf(t), t]));
489
+ for (const key of [.../* @__PURE__ */ new Set([...A.keys(), ...B.keys()])].sort()) {
490
+ const a = A.get(key);
491
+ const b2 = B.get(key);
492
+ if (a && !b2) {
493
+ rows.push({ section, key, kind: "only_in_target", detail: `${section} '${key}' exists on the target, not on --against` });
494
+ continue;
495
+ }
496
+ if (!a && b2) {
497
+ rows.push({ section, key, kind: "only_in_against", detail: `${section} '${key}' exists on --against, not on the target` });
498
+ continue;
499
+ }
500
+ for (const f of fields) {
501
+ const av = f.of(a);
502
+ const bv = f.of(b2);
503
+ const equal = Array.isArray(av) && Array.isArray(bv) ? sameSet(av, bv) : JSON.stringify(av ?? null) === JSON.stringify(bv ?? null);
504
+ if (equal) continue;
505
+ rows.push({
506
+ section,
507
+ key,
508
+ kind: "differs",
509
+ detail: `${section} '${key}' ${f.name}: ${JSON.stringify(av ?? null)} \u2192 ${JSON.stringify(bv ?? null)}` + (f.note ? ` ${f.note}` : "")
510
+ });
511
+ }
512
+ }
513
+ return rows;
514
+ }
515
+ function diffApiState(target, against) {
516
+ return [
517
+ ...diffBy("policy", target.policies, against.policies, (p) => p.name, [
518
+ { name: "limit", of: (p) => p.limit },
519
+ { name: "window_seconds", of: (p) => p.window_seconds },
520
+ { name: "behavior", of: (p) => p.behavior },
521
+ // PUT /v1/rate-limits/policies/:id cannot repair a key_template (the
522
+ // update body omits it) — the only fix is delete-and-recreate.
523
+ { name: "key_template", of: (p) => p.key_template, note: "(recreate to fix)" },
524
+ // Normalized at the accessor too, so a projection built by hand (or by an
525
+ // older CLI) can never compare `undefined` against the default.
526
+ { name: "algorithm", of: (p) => p.algorithm ?? LEGACY_POLICY_ALGORITHM }
527
+ ]),
528
+ ...diffBy("subscription", target.subscriptions, against.subscriptions, (s) => s.target_url, [
529
+ { name: "event_prefixes", of: (s) => s.event_prefixes },
530
+ { name: "state", of: (s) => s.state ?? DEFAULT_SUBSCRIPTION_STATE }
531
+ ]),
532
+ // Campaigns compare by PRESENCE only: everything else about a campaign
533
+ // (its schedule, its audience, its run state) is operational state that
534
+ // legitimately differs between a staging and a production tenant.
535
+ ...diffBy("campaign", target.campaigns, against.campaigns, (c) => c.name, [])
536
+ ];
537
+ }
538
+ function truncationWarnings(snap, side) {
539
+ return snap.truncated.map((s) => {
540
+ const cap = s === "policy" ? POLICY_LIST_CAP : CAMPAIGN_LIST_CAP;
541
+ return ` ! ${s} list on ${side} returned ${cap} rows \u2014 that is the route's HARD cap (no cursor), so the remainder cannot be diffed at all`;
542
+ });
543
+ }
544
+ function formatApiStateRows(rows) {
545
+ if (!rows.length) return " (api state in sync)";
546
+ return rows.map((r) => ` ${r.kind === "differs" ? "~" : r.kind === "only_in_target" ? "+" : "-"} ${r.detail}`).join("\n");
547
+ }
344
548
 
345
549
  // src/functions.ts
346
- import { resolve as resolve2, relative, isAbsolute, sep } from "node:path";
550
+ import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync } from "node:fs";
551
+ import { homedir } from "node:os";
552
+ import { dirname as dirname2, resolve as resolve2, relative, isAbsolute, sep } from "node:path";
347
553
  import { createHash } from "node:crypto";
348
554
  var MAX_SOURCE_BYTES = 256e3;
349
555
  function sourceSha12(source) {
@@ -353,25 +559,89 @@ function escapesRoot(root, path) {
353
559
  const rel = relative(root, path);
354
560
  return rel.startsWith("..") || isAbsolute(rel);
355
561
  }
356
- function assertBundleContained(projectRoot, inputs) {
562
+ function realpathSafe(p) {
563
+ try {
564
+ return realpathSync.native(resolve2(p));
565
+ } catch {
566
+ return resolve2(p);
567
+ }
568
+ }
569
+ function isWorkspaceMarker(dir) {
570
+ if (existsSync2(resolve2(dir, "pnpm-workspace.yaml"))) return true;
571
+ if (existsSync2(resolve2(dir, "turbo.json"))) return true;
572
+ if (existsSync2(resolve2(dir, "lerna.json"))) return true;
573
+ const pkg = resolve2(dir, "package.json");
574
+ if (!existsSync2(pkg)) return false;
575
+ try {
576
+ return JSON.parse(readFileSync2(pkg, "utf8")).workspaces !== void 0;
577
+ } catch {
578
+ return false;
579
+ }
580
+ }
581
+ function workspaceRootFor(projectRoot) {
582
+ const start = realpathSafe(projectRoot);
583
+ const home = realpathSafe(homedir());
584
+ const underHome = !escapesRoot(home, start);
585
+ let gitRoot = null;
586
+ let dir = start;
587
+ for (; ; ) {
588
+ if (isWorkspaceMarker(dir)) return dir;
589
+ if (existsSync2(resolve2(dir, ".git")) && existsSync2(resolve2(dir, "package.json"))) gitRoot = dir;
590
+ if (underHome && dir === home) break;
591
+ const up = dirname2(dir);
592
+ if (up === dir) break;
593
+ dir = up;
594
+ }
595
+ return gitRoot ?? start;
596
+ }
597
+ var WORKSPACE_ROOTS = /* @__PURE__ */ new Map();
598
+ function workspaceRootCached(projectRoot) {
599
+ const key = resolve2(projectRoot);
600
+ let v = WORKSPACE_ROOTS.get(key);
601
+ if (v === void 0) {
602
+ v = workspaceRootFor(key);
603
+ WORKSPACE_ROOTS.set(key, v);
604
+ }
605
+ return v;
606
+ }
607
+ function assertBundleContained(projectRoot, inputs, opts = {}) {
608
+ const what = opts.what ?? "bundled import";
357
609
  const root = resolve2(projectRoot);
610
+ const rootReal = realpathSafe(root);
611
+ const wsReal = realpathSafe(opts.workspaceRoot ?? workspaceRootCached(root));
612
+ const note = opts.onEscape ?? ((msg) => console.log(msg));
358
613
  for (const input of inputs) {
359
614
  const abs = resolve2(root, input);
360
- if (!escapesRoot(root, abs)) continue;
361
- if (abs.split(sep).includes("node_modules")) continue;
615
+ const absReal = realpathSafe(abs);
616
+ if (!escapesRoot(rootReal, absReal) || !escapesRoot(root, abs)) continue;
617
+ if (absReal.split(sep).includes("node_modules") || abs.split(sep).includes("node_modules")) continue;
618
+ const pkg = workspacePackageFor(wsReal, absReal);
619
+ if (pkg !== null) {
620
+ note(`! ${what} '${input}' comes from the workspace package ${pkg}`);
621
+ continue;
622
+ }
362
623
  throw new Error(
363
- `functions: bundled import '${input}' resolves outside the project root (${root}) \u2014 function source (and everything it imports) must live inside the project or node_modules`
624
+ `functions: ${what} '${input}' resolves outside the project root (${rootReal})${wsReal === rootReal ? "" : ` and outside any package of its workspace (${wsReal})`} \u2014 function source (and everything it imports) must live inside the project, a workspace PACKAGE (a directory with its own package.json), or node_modules${wsReal === rootReal ? "" : " (run `vxil push` from the workspace root if the import is a sibling package)"}`
364
625
  );
365
626
  }
366
627
  }
628
+ function workspacePackageFor(workspaceRoot, path) {
629
+ const wsReal = realpathSafe(workspaceRoot);
630
+ if (escapesRoot(wsReal, path)) return null;
631
+ let dir = dirname2(path);
632
+ for (; ; ) {
633
+ if (dir === wsReal || !dir.startsWith(wsReal + sep)) return null;
634
+ if (existsSync2(resolve2(dir, "package.json"))) return dir;
635
+ const up = dirname2(dir);
636
+ if (up === dir) return null;
637
+ dir = up;
638
+ }
639
+ }
367
640
  async function bundleFunction(entryPath, opts = {}) {
368
641
  const root = resolve2(opts.projectRoot ?? process.cwd());
642
+ const workspaceRoot = workspaceRootCached(root);
369
643
  const entry = resolve2(entryPath);
370
- if (escapesRoot(root, entry)) {
371
- throw new Error(
372
- `functions: entry '${entryPath}' resolves outside the project root (${root}) \u2014 declare function entries as paths inside the project`
373
- );
374
- }
644
+ assertBundleContained(root, [entry], { workspaceRoot, what: "entry" });
375
645
  const esbuild = await import("esbuild");
376
646
  const result = await esbuild.build({
377
647
  entryPoints: [entry],
@@ -390,7 +660,7 @@ async function bundleFunction(entryPath, opts = {}) {
390
660
  });
391
661
  const out = result.outputFiles?.[0];
392
662
  if (!out) throw new Error(`esbuild produced no output for ${entryPath}`);
393
- assertBundleContained(root, Object.keys(result.metafile?.inputs ?? {}));
663
+ assertBundleContained(root, Object.keys(result.metafile?.inputs ?? {}), { workspaceRoot });
394
664
  const source = out.text;
395
665
  const bytes = Buffer.byteLength(source, "utf8");
396
666
  if (bytes > MAX_SOURCE_BYTES) {
@@ -418,8 +688,9 @@ function normalizeSecretRefs(refs) {
418
688
  async function readRemote(api) {
419
689
  const res = await api("GET", "/v1/functions");
420
690
  const map = /* @__PURE__ */ new Map();
421
- if (res.status !== 200) return map;
422
- const fns = res.body.data?.functions ?? [];
691
+ const unavailable = readUnavailable("GET /v1/functions", res);
692
+ if (res.status !== 200) return { map, unavailable };
693
+ const fns = Array.isArray(res.body.data?.functions) ? res.body.data.functions : [];
423
694
  for (const f of fns) {
424
695
  map.set(f.name, {
425
696
  scriptRef: f.scriptRef ?? "",
@@ -427,10 +698,11 @@ async function readRemote(api) {
427
698
  scopes: f.scopes ?? [],
428
699
  egressAllow: f.egressAllow ?? [],
429
700
  bindings: f.bindings ?? [],
430
- signature: f.signature ?? null
701
+ signature: f.signature ?? null,
702
+ limits: f.limits ?? null
431
703
  });
432
704
  }
433
- return map;
705
+ return { map, unavailable: null };
434
706
  }
435
707
  function sameSecretSet(a, b2) {
436
708
  return a.length === b2.length && a.every((x) => b2.includes(x));
@@ -445,7 +717,16 @@ function clampFunctionScopes(scopes) {
445
717
  return (scopes ?? []).map(String).filter((s) => s && !DENY_FUNCTION_SCOPES.has(s));
446
718
  }
447
719
  async function planFunctions({ api, functions, cwd, apply }) {
448
- const remote = await readRemote(api);
720
+ const read = await readRemote(api);
721
+ if (read.unavailable) {
722
+ if (apply) {
723
+ throw new Error(
724
+ `functions: cannot read ${read.unavailable.route} (${read.unavailable.code ?? read.unavailable.status}${read.unavailable.message ? ` \u2014 ${read.unavailable.message}` : ""}) \u2014 refusing to deploy a plan computed against an unread target`
725
+ );
726
+ }
727
+ return { changes: [], applied: 0, cmsHookSubscriptions: null, webhookSubscriptions: null, remoteUnavailable: read.unavailable };
728
+ }
729
+ const remote = read.map;
449
730
  const results = await mapPool(Object.entries(functions), 1, async ([name, def]) => {
450
731
  if (!/^[a-z][a-z0-9-]{0,47}$/.test(name)) {
451
732
  throw new Error(`functions: invalid name '${name}' (must match /^[a-z][a-z0-9-]{0,47}$/)`);
@@ -454,11 +735,12 @@ async function planFunctions({ api, functions, cwd, apply }) {
454
735
  const triggerKind = def.trigger?.kind ?? "http";
455
736
  const secrets = normalizeSecretRefs(def.secrets);
456
737
  const bindings = lowerTriggerBindings(def.trigger);
738
+ const limits = normalizeFnLimits(def.limits);
457
739
  const remoteFn = remote.get(name);
458
740
  const sha = sourceSha12(source);
459
- const label = `${name} (${Math.round(bytes / 100) / 10} KB) [${triggerKind}${def.scopes?.length ? " \xB7 " + def.scopes.join(",") : ""}${secrets.length ? " \xB7 secrets:" + secrets.map((s) => s.slice("secret:".length)).join(",") : ""}]`;
460
- if (remoteFn !== void 0 && remoteFn.scriptRef.endsWith(`-${sha}`) && sameSecretSet(remoteFn.secrets, secrets) && sameStringSet(clampFunctionScopes(def.scopes), remoteFn.scopes) && sameStringSet(def.egressAllow ?? [], remoteFn.egressAllow) && stableStringify(remoteFn.bindings) === stableStringify(bindings) && stableStringify(remoteFn.signature) === stableStringify(def.signature ?? null)) {
461
- return { change: { name, kind: "unchanged", detail: label }, applied: 0, cmsHooks: null };
741
+ const label = `${name} (${Math.round(bytes / 100) / 10} KB) [${triggerKind}${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" : ""}]`;
742
+ if (remoteFn !== void 0 && remoteFn.scriptRef.endsWith(`-${sha}`) && sameSecretSet(remoteFn.secrets, secrets) && sameStringSet(clampFunctionScopes(def.scopes), remoteFn.scopes) && sameStringSet(def.egressAllow ?? [], remoteFn.egressAllow) && stableStringify(remoteFn.bindings) === stableStringify(bindings) && stableStringify(remoteFn.signature) === stableStringify(def.signature ?? null) && stableStringify(remoteFn.limits ?? null) === stableStringify(limits ?? null)) {
743
+ return { change: { name, kind: "unchanged", detail: label }, applied: 0, cmsHooks: null, webhooks: null };
462
744
  }
463
745
  const change = { name, kind: remoteFn === void 0 ? "deploy-new" : "deploy-update", detail: label };
464
746
  if (apply) {
@@ -467,23 +749,29 @@ async function planFunctions({ api, functions, cwd, apply }) {
467
749
  if (def.signature) body.signature = def.signature;
468
750
  if (def.scopes) body.scopes = def.scopes;
469
751
  if (def.egressAllow) body.egressAllow = def.egressAllow;
752
+ if (limits) body.limits = limits;
470
753
  const res = await api("POST", `/v1/functions/${encodeURIComponent(name)}`, body);
471
754
  if (res.status !== 200 && res.status !== 201) {
472
755
  const e = res.body.error ?? {};
473
756
  throw new Error(`functions: deploy ${name} failed: ${e.code ?? res.status} ${e.message ?? ""} ${e.hint ?? ""}`);
474
757
  }
475
- const cmsHooks = res.body.data?.cms_hook_subscriptions ?? null;
476
- return { change, applied: 1, cmsHooks };
758
+ const data2 = res.body.data;
759
+ const cmsHooks = data2?.cms_hook_subscriptions ?? null;
760
+ const webhooks = data2?.webhook_subscriptions ?? null;
761
+ return { change, applied: 1, cmsHooks, webhooks };
477
762
  }
478
- return { change, applied: 0, cmsHooks: null };
763
+ return { change, applied: 0, cmsHooks: null, webhooks: null };
479
764
  });
480
765
  const changes = results.map((r) => r.change);
481
766
  const applied = results.reduce((s, r) => s + r.applied, 0);
482
- const cmsHookSubscriptions = results.reduce((acc, r) => {
483
- if (!r.cmsHooks) return acc;
484
- return { created: (acc?.created ?? 0) + r.cmsHooks.created, deleted: (acc?.deleted ?? 0) + r.cmsHooks.deleted };
767
+ const sum = (pick) => results.reduce((acc, r) => {
768
+ const v = pick(r);
769
+ if (!v) return acc;
770
+ return { created: (acc?.created ?? 0) + v.created, deleted: (acc?.deleted ?? 0) + v.deleted };
485
771
  }, null);
486
- return { changes, applied, cmsHookSubscriptions };
772
+ const cmsHookSubscriptions = sum((r) => r.cmsHooks);
773
+ const webhookSubscriptions = sum((r) => r.webhooks);
774
+ return { changes, applied, cmsHookSubscriptions, webhookSubscriptions };
487
775
  }
488
776
  function formatFnChanges(changes) {
489
777
  const actionable = changes.filter((c) => c.kind !== "unchanged");
@@ -510,21 +798,21 @@ function normalizeActions(raw) {
510
798
  return raw.filter((a) => !!a && typeof a === "object").map((a) => ({ key: String(a.key), label: String(a.label), fn: String(a.fn) }));
511
799
  }
512
800
  var FIELD_ATTRS = [
513
- { config: "required", wire: "required", remote: "required", blank: false, alterable: false },
514
- { config: "validation", wire: "validation", remote: "validation", blank: {}, alterable: false },
515
- { config: "indexSlot", wire: "index_slot", remote: "index_slot", blank: null, alterable: false },
516
- { config: "relationTo", wire: "relation_to", remote: "relation_to", blank: null, alterable: false },
517
- { config: "computed", wire: "computed", remote: "computed", blank: false, alterable: false },
518
- { config: "compute", wire: "compute", remote: "compute", blank: null, alterable: false },
519
- { config: "unique", wire: "unique", remote: "is_unique", blank: false, alterable: true },
520
- { config: "onDelete", wire: "on_delete", remote: "on_delete", blank: null, alterable: true },
801
+ { config: "required", wire: "required", remote: "required", blank: false, alter: "in-place" },
802
+ { config: "validation", wire: "validation", remote: "validation", blank: {}, alter: "in-place" },
803
+ { config: "indexSlot", wire: "index_slot", remote: "index_slot", blank: null, alter: "reindex" },
804
+ { config: "relationTo", wire: "relation_to", remote: "relation_to", blank: null, alter: "destructive" },
805
+ { config: "computed", wire: "computed", remote: "computed", blank: false, alter: "in-place" },
806
+ { config: "compute", wire: "compute", remote: "compute", blank: null, alter: "in-place" },
807
+ { config: "unique", wire: "unique", remote: "is_unique", blank: false, alter: "tighten-only" },
808
+ { config: "onDelete", wire: "on_delete", remote: "on_delete", blank: null, alter: "tighten-only" },
521
809
  // cms.md §18 (RB-3) field-level read security. ALTERABLE: cms-v1 rewrites the
522
810
  // gate in place on a same-type re-POST (the unique/on_delete flag-alter path),
523
811
  // so config-as-code can TIGHTEN a gate on an existing field. Blank is `null`
524
812
  // (the server stores NULL for "ungated" and normalizes `[]` to NULL), so an
525
813
  // omitted attribute diffs clean; CLEARING it WIDENS access and therefore rides
526
814
  // the same --allow-destructive gate as clearing unique/on_delete/owner_field.
527
- { config: "readRoles", wire: "read_roles", remote: "read_roles", blank: null, alterable: true }
815
+ { config: "readRoles", wire: "read_roles", remote: "read_roles", blank: null, alter: "tighten-only" }
528
816
  ];
529
817
  function fieldToInput(name, def) {
530
818
  const out = { field: name, type: def.type };
@@ -547,41 +835,110 @@ function remoteFieldToConfig(rf) {
547
835
  function fieldAttrDelta(def, rf) {
548
836
  const d = def;
549
837
  const r = rf;
550
- const flags = [];
551
- const clearing = [];
552
- const drift = [];
838
+ const out = { flags: [], clearing: [], inPlace: [], reindex: [], retarget: [], drift: [] };
553
839
  for (const a of FIELD_ATTRS) {
554
840
  const wantBlank = stableStringify(a.blank);
555
841
  const want = stableStringify(d[a.config] ?? a.blank);
556
842
  const have = stableStringify(r[a.remote] ?? a.blank);
557
843
  if (want === have) continue;
558
- if (!a.alterable) {
559
- drift.push(`${a.config} ${have} \u2192 ${want}`);
844
+ const line = `${a.config} ${have} \u2192 ${want}`;
845
+ if (a.alter === "tighten-only") {
846
+ (want === wantBlank ? out.clearing : out.flags).push(line);
847
+ continue;
848
+ }
849
+ if (d[a.config] === void 0) {
850
+ out.drift.push(line);
560
851
  continue;
561
852
  }
562
- if (want === wantBlank) clearing.push(`${a.config} ${have} \u2192 ${want}`);
563
- else flags.push(`${a.config} ${have} \u2192 ${want}`);
853
+ if (a.alter === "reindex") out.reindex.push(line);
854
+ else if (a.alter === "destructive") out.retarget.push(line);
855
+ else out.inPlace.push(line);
856
+ }
857
+ return out;
858
+ }
859
+ function vacateSlotBody(rf) {
860
+ const out = { field: rf.field, type: rf.type, index_slot: null };
861
+ if (rf.is_unique) out.unique = true;
862
+ if (rf.on_delete) out.on_delete = rf.on_delete;
863
+ if (rf.read_roles && rf.read_roles.length > 0) out.read_roles = rf.read_roles;
864
+ return out;
865
+ }
866
+ async function liveRowCount(api, collection) {
867
+ try {
868
+ const res = await api("GET", `/v1/cms/items/${encodeURIComponent(collection)}?count=true`);
869
+ const n = res.body.data?.count;
870
+ return typeof n === "number" ? n : null;
871
+ } catch {
872
+ return null;
564
873
  }
565
- return { flags, clearing, drift };
566
874
  }
567
875
  async function readRemote2(api) {
568
876
  const res = await api("GET", "/v1/cms/collections");
569
877
  const map = /* @__PURE__ */ new Map();
570
- if (res.status !== 200) return map;
571
- const cols = res.body.data?.collections ?? [];
878
+ const unavailable = readUnavailable("GET /v1/cms/collections", res);
879
+ if (res.status !== 200) return { map, unavailable };
880
+ const cols = Array.isArray(res.body.data?.collections) ? res.body.data.collections : [];
572
881
  for (const c of cols) {
573
882
  map.set(c.collection, {
574
883
  collection: c.collection,
575
884
  ownerField: c.owner_field ?? null,
576
885
  public: c.public === true,
577
886
  actions: normalizeActions(c.actions),
578
- fields: c.fields ?? []
887
+ fields: Array.isArray(c.fields) ? c.fields : []
579
888
  });
580
889
  }
581
- return map;
890
+ return { map, unavailable: null };
891
+ }
892
+ var REINDEX_PAGE_CAP = 1e4;
893
+ async function reindexCollection({ api, collection, field, onProgress }) {
894
+ const rerun = ` \u2014 re-run \`vxil cms reindex ${collection}${field ? ` --field ${field}` : ""}\` (the alter is applied; only the projection lags)`;
895
+ let cursor;
896
+ let scanned = 0;
897
+ let updated = 0;
898
+ let pages = 0;
899
+ let skipped = 0;
900
+ const page = async () => {
901
+ const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(collection)}/reindex`, {
902
+ ...field ? { field } : {},
903
+ ...cursor ? { cursor } : {}
904
+ });
905
+ if (res.status < 200 || res.status >= 300) {
906
+ const e = res.body.error ?? {};
907
+ throw new Error(`cms: re-index ${collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`.trimEnd() + rerun);
908
+ }
909
+ const d = res.body.data ?? {};
910
+ scanned += d.scanned ?? 0;
911
+ updated += d.updated ?? 0;
912
+ pages++;
913
+ return d;
914
+ };
915
+ for (; ; ) {
916
+ let d = await page();
917
+ if ((d.skipped ?? 0) > 0) d = await page();
918
+ skipped += d.skipped ?? 0;
919
+ onProgress?.({ scanned, updated, pages, skipped });
920
+ if (d.complete === true || !d.next_cursor) break;
921
+ if (d.next_cursor === cursor) {
922
+ throw new Error(`cms: re-index ${collection} stalled \u2014 the server returned the same cursor twice after ${scanned} row(s)${rerun}`);
923
+ }
924
+ if (pages >= REINDEX_PAGE_CAP) {
925
+ throw new Error(`cms: re-index ${collection} exceeded ${REINDEX_PAGE_CAP} pages (${scanned} row(s) scanned) without completing${rerun}`);
926
+ }
927
+ cursor = d.next_cursor;
928
+ }
929
+ return { scanned, updated, pages, skipped };
582
930
  }
583
- async function planCmsSchema({ api, collections, apply, allowDestructive = false }) {
584
- const remote = await readRemote2(api);
931
+ async function planCmsSchema({ api, collections, apply, allowDestructive = false, onProgress }) {
932
+ const read = await readRemote2(api);
933
+ if (read.unavailable) {
934
+ if (apply) {
935
+ throw new Error(
936
+ `cms: cannot read ${read.unavailable.route} (${read.unavailable.code ?? read.unavailable.status}${read.unavailable.message ? ` \u2014 ${read.unavailable.message}` : ""}) \u2014 refusing to apply a schema plan computed against an unread target`
937
+ );
938
+ }
939
+ return { changes: [], applied: 0, remoteUnavailable: read.unavailable };
940
+ }
941
+ const remote = read.map;
585
942
  const changes = [];
586
943
  for (const [name, def] of Object.entries(collections)) {
587
944
  const declaredFields = Object.entries(def.fields ?? {});
@@ -594,13 +951,30 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
594
951
  });
595
952
  continue;
596
953
  }
954
+ const pre = [];
955
+ const collChanges = [];
956
+ const post = [];
957
+ const slotMoved = [];
597
958
  const remoteByName = new Map(rc.fields.map((f) => [f.field, f]));
598
959
  for (const [fn, fd] of declaredFields) {
599
960
  const existing = remoteByName.get(fn);
600
961
  if (!existing) {
601
- changes.push({ collection: name, kind: "add-field", field: fn, detail: `${name}.${fn} (${fd.type})` });
962
+ collChanges.push({ collection: name, kind: "add-field", field: fn, detail: `${name}.${fn} (${fd.type})` });
602
963
  } else if (existing.type !== fd.type) {
603
- changes.push({
964
+ if (allowDestructive && (fd.indexSlot ?? null) !== (existing.index_slot ?? null)) {
965
+ if (existing.index_slot) {
966
+ pre.push({
967
+ collection: name,
968
+ kind: "destructive",
969
+ op: "vacate-slot",
970
+ field: fn,
971
+ remote: existing,
972
+ detail: `${name}.${fn} vacate index_slot ${existing.index_slot} (two-pass: vacate \u2192 claim)`
973
+ });
974
+ }
975
+ slotMoved.push(fn);
976
+ }
977
+ collChanges.push({
604
978
  collection: name,
605
979
  kind: "destructive",
606
980
  op: "alter-field",
@@ -608,61 +982,73 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
608
982
  detail: `${name}.${fn} type ${existing.type} \u2192 ${fd.type} (destructive alter)`
609
983
  });
610
984
  } else {
611
- const { flags, clearing, drift } = fieldAttrDelta(fd, existing);
612
- if (flags.length && clearing.length === 0) {
613
- changes.push({
614
- collection: name,
615
- kind: "alter-flags",
616
- field: fn,
617
- detail: `${name}.${fn} ${flags.join(", ")} (non-destructive in-place alter)`
618
- });
619
- }
620
- if (clearing.length) {
621
- const both = [...clearing, ...flags];
985
+ const { flags, clearing, inPlace, reindex, retarget, drift } = fieldAttrDelta(fd, existing);
986
+ const destructiveAttrs = [...clearing, ...reindex, ...retarget];
987
+ if (destructiveAttrs.length) {
988
+ const all = [...destructiveAttrs, ...flags, ...inPlace];
622
989
  if (allowDestructive) {
623
- changes.push({
990
+ if (reindex.length && existing.index_slot) {
991
+ pre.push({
992
+ collection: name,
993
+ kind: "destructive",
994
+ op: "vacate-slot",
995
+ field: fn,
996
+ remote: existing,
997
+ detail: `${name}.${fn} vacate index_slot ${existing.index_slot} (two-pass: vacate \u2192 claim)`
998
+ });
999
+ }
1000
+ collChanges.push({
624
1001
  collection: name,
625
1002
  kind: "destructive",
626
1003
  op: "alter-field",
627
1004
  field: fn,
628
- detail: `${name}.${fn} ${both.join(", ")} \u2014 clearing a live invariant (unique claims / on_delete cascade) (full-config reconciliation)`
1005
+ detail: `${name}.${fn} ${all.join(", ")}` + (clearing.length ? " \u2014 clearing a live invariant (unique claims / on_delete cascade / read_roles gate)" : "") + (reindex.length ? " \u2014 an index_slot move: stored rows keep the OLD projection until the re-index runs" : "") + (retarget.length ? " \u2014 a relation retarget: every stored id now names a different collection" : "") + " (full-config reconciliation)"
629
1006
  });
1007
+ if (reindex.length) slotMoved.push(fn);
630
1008
  } else {
631
- changes.push({
1009
+ collChanges.push({
632
1010
  collection: name,
633
1011
  kind: "warn",
634
1012
  field: fn,
635
- detail: `${name}.${fn} ${both.join(", ")}: this CLEARS a live invariant \u2014 clearing unique DROPS the uniqueness claims (duplicates then accepted, unrecoverable) and clearing on_delete stops cascades, so it is NOT applied (nor is any co-declared tightening on this field). Declare the attribute to keep it, or re-run with --allow-destructive to clear.`
1013
+ detail: `${name}.${fn} ${all.join(", ")}: this is DESTRUCTIVE-SHAPED \u2014 ` + (clearing.length ? "it CLEARS a live invariant (clearing unique DROPS the uniqueness claims \u2014 duplicates then accepted, unrecoverable; clearing on_delete stops cascades; clearing read_roles WIDENS the field to every end-user); " : "") + (reindex.length ? "an index_slot move leaves stored rows on the OLD projection until a re-index, so queries on the field return the WRONG rows; " : "") + (retarget.length ? "a relation retarget repoints every stored id at another collection; " : "") + "it is NOT applied (nor is any co-declared in-place change on this field). Re-run with --allow-destructive to apply."
636
1014
  });
637
1015
  }
1016
+ } else if (flags.length || inPlace.length) {
1017
+ collChanges.push({
1018
+ collection: name,
1019
+ kind: "alter-flags",
1020
+ field: fn,
1021
+ detail: `${name}.${fn} ${[...flags, ...inPlace].join(", ")} (non-destructive in-place alter)`
1022
+ });
638
1023
  }
639
1024
  if (drift.length) {
640
- changes.push({
1025
+ collChanges.push({
641
1026
  collection: name,
642
1027
  kind: "warn",
643
1028
  field: fn,
644
- detail: `${name}.${fn} drift: ${drift.join("; ")} \u2014 cms-v1 has no same-type in-place alter for these (only unique/on_delete alter in place); the difference is NOT applied. Match the config to the live definition, or re-create the field.`
1029
+ detail: `${name}.${fn} drift: ${drift.join("; ")} \u2014 the config does NOT declare these, so they are left exactly as the live schema has them (an omitted attribute is never blanked). Declare them in vxil.config.ts to make config authoritative.`
645
1030
  });
646
1031
  }
1032
+ continue;
647
1033
  }
648
1034
  }
649
1035
  const declaredOwner = def.ownerField ?? null;
650
1036
  const remoteOwner = rc.ownerField;
651
1037
  if (declaredOwner !== remoteOwner) {
652
1038
  if (declaredOwner !== null) {
653
- changes.push({
1039
+ collChanges.push({
654
1040
  collection: name,
655
1041
  kind: "set-owner-field",
656
1042
  detail: `${name} owner_field \u2192 '${declaredOwner}'${remoteOwner ? ` (was '${remoteOwner}')` : ""} (end-user owner-scoping, non-destructive)`
657
1043
  });
658
1044
  } else if (allowDestructive) {
659
- changes.push({
1045
+ collChanges.push({
660
1046
  collection: name,
661
1047
  kind: "set-owner-field",
662
1048
  detail: `${name} owner_field cleared (was '${remoteOwner}') \u2014 end-user owner-scoping OFF (full-config reconciliation)`
663
1049
  });
664
1050
  } else {
665
- changes.push({
1051
+ collChanges.push({
666
1052
  collection: name,
667
1053
  kind: "warn",
668
1054
  detail: `${name}: owner_field '${remoteOwner}' is set remotely but absent in config \u2014 clearing it WIDENS access to every same-tenant end user, so it is NOT applied. Declare ownerField to keep it, or re-run with --allow-destructive to clear.`
@@ -671,7 +1057,7 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
671
1057
  }
672
1058
  const declaredPublic = def.public === true;
673
1059
  if (declaredPublic !== rc.public) {
674
- changes.push({
1060
+ collChanges.push({
675
1061
  collection: name,
676
1062
  kind: "set-public",
677
1063
  detail: `${name} public \u2192 ${declaredPublic} (keyless public delivery ${declaredPublic ? "ON" : "OFF"})`
@@ -679,7 +1065,7 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
679
1065
  }
680
1066
  const declaredActions = normalizeActions(def.actions);
681
1067
  if (stableStringify(declaredActions) !== stableStringify(rc.actions)) {
682
- changes.push({
1068
+ collChanges.push({
683
1069
  collection: name,
684
1070
  kind: "set-actions",
685
1071
  detail: `${name} actions \u2192 [${declaredActions.map((a) => `${a.key}\u2192${a.fn}`).join(", ")}] (was [${rc.actions.map((a) => `${a.key}\u2192${a.fn}`).join(", ")}])`
@@ -687,7 +1073,7 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
687
1073
  }
688
1074
  for (const f of rc.fields) {
689
1075
  if (!Object.prototype.hasOwnProperty.call(def.fields ?? {}, f.field)) {
690
- changes.push({
1076
+ collChanges.push({
691
1077
  collection: name,
692
1078
  kind: "destructive",
693
1079
  op: "drop-field",
@@ -696,6 +1082,15 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
696
1082
  });
697
1083
  }
698
1084
  }
1085
+ if (slotMoved.length) {
1086
+ const n = await liveRowCount(api, name);
1087
+ post.push({
1088
+ collection: name,
1089
+ kind: "reindex",
1090
+ detail: `${name} re-index ${n === null ? "live rows" : `${n} live rows`} after the index_slot move on ${slotMoved.join(", ")}`
1091
+ });
1092
+ }
1093
+ changes.push(...pre, ...collChanges, ...post);
699
1094
  }
700
1095
  if (allowDestructive) {
701
1096
  for (const rname of remote.keys()) {
@@ -773,6 +1168,16 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
773
1168
  throw new Error(`cms: set actions on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
774
1169
  }
775
1170
  applied++;
1171
+ } else if (c.kind === "reindex") {
1172
+ const done = await reindexCollection({
1173
+ api,
1174
+ collection: c.collection,
1175
+ onProgress: (p) => onProgress?.(` \u2026 re-indexed ${p.scanned} row(s) of ${c.collection}` + (p.skipped > 0 ? ` (${p.skipped} skipped \u2014 written concurrently)` : ""))
1176
+ });
1177
+ if (done.skipped > 0) {
1178
+ onProgress?.(` \u26A0 ${done.skipped} row(s) of ${c.collection} were written while the re-index ran and were NOT re-projected \u2014 re-run \`vxil cms reindex ` + c.collection + "` when writes are quiet");
1179
+ }
1180
+ applied++;
776
1181
  } else if (c.kind === "destructive") {
777
1182
  applied += await applyDestructive(api, collections, c);
778
1183
  }
@@ -799,6 +1204,15 @@ async function applyDestructive(api, collections, c) {
799
1204
  }
800
1205
  return 1;
801
1206
  }
1207
+ if (c.op === "vacate-slot") {
1208
+ const rf = c.remote;
1209
+ const res2 = await api("POST", path, vacateSlotBody(rf));
1210
+ if (!ok2xx(res2.status) && res2.body.error?.code !== "already_exists") {
1211
+ const e = res2.body.error ?? {};
1212
+ throw new Error(`cms: vacate ${c.collection}.${c.field} slot failed: ${e.code ?? res2.status} ${e.message ?? ""}`);
1213
+ }
1214
+ return 1;
1215
+ }
802
1216
  const fd = collections[c.collection].fields[c.field];
803
1217
  const res = await api("POST", path, { ...fieldToInput(c.field, fd), allow_destructive: true });
804
1218
  if (!ok2xx(res.status)) {
@@ -812,6 +1226,7 @@ function formatCmsChanges(changes) {
812
1226
  return changes.map((c) => {
813
1227
  if (c.kind === "destructive") return ` ! ${c.detail}`;
814
1228
  if (c.kind === "warn") return ` \u26A0 ${c.detail}`;
1229
+ if (c.kind === "reindex") return ` \u21BB ${c.detail}`;
815
1230
  if (c.kind === "alter-flags" || c.kind === "set-owner-field") return ` ~ ${c.detail}`;
816
1231
  return ` + ${c.detail}`;
817
1232
  }).join("\n");
@@ -1334,8 +1749,8 @@ function typeHandlers(types2) {
1334
1749
  function escapeIdentifiers(xs, { transform: { column } }) {
1335
1750
  return xs.map((x) => escapeIdentifier(column.to ? column.to(x) : x)).join(",");
1336
1751
  }
1337
- var escapeIdentifier = function escape(str) {
1338
- return '"' + str.replace(/"/g, '""').replace(/\./g, '"."') + '"';
1752
+ var escapeIdentifier = function escape(str2) {
1753
+ return '"' + str2.replace(/"/g, '""').replace(/\./g, '"."') + '"';
1339
1754
  };
1340
1755
  var inferType = function inferType2(x) {
1341
1756
  return x instanceof Parameter ? x.type : x instanceof Date ? 1184 : x instanceof Uint8Array ? 17 : x === true || x === false ? 16 : typeof x === "bigint" ? 20 : Array.isArray(x) ? inferType2(x[0]) : 0;
@@ -1410,16 +1825,16 @@ function arrayParserLoop(s, x, parser, typarray) {
1410
1825
  return xs;
1411
1826
  }
1412
1827
  var toCamel = (x) => {
1413
- let str = x[0];
1828
+ let str2 = x[0];
1414
1829
  for (let i = 1; i < x.length; i++)
1415
- str += x[i] === "_" ? x[++i].toUpperCase() : x[i];
1416
- return str;
1830
+ str2 += x[i] === "_" ? x[++i].toUpperCase() : x[i];
1831
+ return str2;
1417
1832
  };
1418
1833
  var toPascal = (x) => {
1419
- let str = x[0].toUpperCase();
1834
+ let str2 = x[0].toUpperCase();
1420
1835
  for (let i = 1; i < x.length; i++)
1421
- str += x[i] === "_" ? x[++i].toUpperCase() : x[i];
1422
- return str;
1836
+ str2 += x[i] === "_" ? x[++i].toUpperCase() : x[i];
1837
+ return str2;
1423
1838
  };
1424
1839
  var toKebab = (x) => x.replace(/_/g, "-");
1425
1840
  var fromCamel = (x) => x.replace(/([A-Z])/g, "_$1").toLowerCase();
@@ -2330,8 +2745,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
2330
2745
  bytes_default.i16(0);
2331
2746
  return bytes_default.end();
2332
2747
  }
2333
- function Parse(str, parameters, types2, name = "") {
2334
- bytes_default().P().str(name + bytes_default.N).str(str + bytes_default.N).i16(parameters.length);
2748
+ function Parse(str2, parameters, types2, name = "") {
2749
+ bytes_default().P().str(name + bytes_default.N).str(str2 + bytes_default.N).i16(parameters.length);
2335
2750
  parameters.forEach((x, i) => bytes_default.i32(types2[i] || 0));
2336
2751
  return bytes_default.end();
2337
2752
  }
@@ -3282,8 +3697,28 @@ function slottedFields(c) {
3282
3697
  function isFilterableField(f) {
3283
3698
  return !(f.computed && !f.index_slot);
3284
3699
  }
3700
+ var MAX_ENUM_UNION = 50;
3701
+ function enumUnionFor(f) {
3702
+ const raw = f.validation?.enum;
3703
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_ENUM_UNION) return null;
3704
+ const base = tsTypeFor(f.type);
3705
+ if (base !== "string" && base !== "number" && base !== "boolean") return null;
3706
+ const members = [];
3707
+ for (const v of raw) {
3708
+ const t = typeof v;
3709
+ if (t !== "string" && t !== "number" && t !== "boolean") return null;
3710
+ if (t !== base) return null;
3711
+ members.push(JSON.stringify(v));
3712
+ }
3713
+ return members.join(" | ");
3714
+ }
3715
+ function hasEnumFilter(c) {
3716
+ return c.fields.some((f) => isFilterableField(f) && enumUnionFor(f) !== null);
3717
+ }
3285
3718
  function filterTypeFor(f) {
3286
3719
  const slot = f.index_slot ?? "";
3720
+ const u = enumUnionFor(f);
3721
+ if (u) return slot ? `VxilFilterRangeEnum<${u}>` : `VxilFilterEnum<${u}>`;
3287
3722
  if (slot.startsWith("s")) return "VxilFilterRangeText";
3288
3723
  if (slot.startsWith("n")) return "VxilFilterRange<number>";
3289
3724
  if (slot.startsWith("t")) return "VxilFilterRangeDatetime";
@@ -3308,15 +3743,16 @@ function unionOf(values2) {
3308
3743
  function collectionType(c) {
3309
3744
  const lines = [];
3310
3745
  const ind = " ";
3746
+ const typeOf = (f) => enumUnionFor(f) ?? tsTypeFor(f.type);
3311
3747
  const rowFields = c.fields.map((f) => {
3312
- const t = tsTypeFor(f.type);
3748
+ const t = typeOf(f);
3313
3749
  return f.required ? `${ind}${quoteKey(f.field)}: ${t};` : `${ind}${quoteKey(f.field)}: ${t} | null;`;
3314
3750
  });
3315
3751
  const insertFields = c.fields.filter((f) => !f.computed).map((f) => {
3316
- const t = tsTypeFor(f.type);
3752
+ const t = typeOf(f);
3317
3753
  return f.required ? `${ind}${quoteKey(f.field)}: ${t};` : `${ind}${quoteKey(f.field)}?: ${t} | null;`;
3318
3754
  });
3319
- const patchFields = c.fields.filter((f) => !f.computed).map((f) => `${ind}${quoteKey(f.field)}?: ${tsTypeFor(f.type)} | null;`);
3755
+ const patchFields = c.fields.filter((f) => !f.computed).map((f) => `${ind}${quoteKey(f.field)}?: ${typeOf(f)} | null;`);
3320
3756
  const slotted = slottedFields(c);
3321
3757
  const sortable = unionOf([...slotted.map((f) => f.field), ...META_SORTABLE]);
3322
3758
  const statusUnion = unionOf([...CMS_STATUS_VALUES]);
@@ -3340,6 +3776,10 @@ ${patchFields.join("\n") || `${ind}/* (no patchable fields) */`}
3340
3776
  };`);
3341
3777
  lines.push(` Sortable: ${sortable};`);
3342
3778
  lines.push(` Filterable: ${filterable};`);
3779
+ const relLines = c.fields.filter((f) => f.type === "relation" || f.type === "file").map((f) => `${ind}${quoteKey(f.field)}: ${f.type === "file" ? "'files'" : JSON.stringify(f.relation_to ?? null)};`);
3780
+ lines.push(` Relations: ${relLines.length ? `{
3781
+ ${relLines.join("\n")}
3782
+ }` : "{}"};`);
3343
3783
  lines.push(` };`);
3344
3784
  return lines.join("\n");
3345
3785
  }
@@ -3382,6 +3822,9 @@ export const API_VERSIONS = {
3382
3822
  ` + apiVers.map(([f, v]) => ` ${quoteKey(f)}: [${v.map((x) => JSON.stringify(x)).join(", ")}],`).join("\n") + `
3383
3823
  } as const;` : "";
3384
3824
  const cmsBody = colls.length ? colls.map(collectionType).join("\n") : " // (no collections \u2014 `vx.from(...)` is empty until you declare one)";
3825
+ const enumAliases = colls.some(hasEnumFilter) ? `
3826
+ export type VxilFilterEnum<U extends string | number | boolean> = U | { $eq?: U; $in?: U[]; $contains?: string };
3827
+ export type VxilFilterRangeEnum<U extends string | number | boolean> = U | { $eq?: U; $ne?: U; $gt?: U; $gte?: U; $lt?: U; $lte?: U; $in?: U[]; $contains?: string };` : "";
3385
3828
  const fnBody = fns.length ? fns.map(functionType).join("\n") : " // (no deployed functions)";
3386
3829
  return `${header}
3387
3830
 
@@ -3414,7 +3857,7 @@ export type VxilFilterEqText = string | { $eq?: string; $in?: string[]; $contain
3414
3857
  export type VxilFilterRange<T> = T | { $eq?: T; $ne?: T; $gt?: T; $gte?: T; $lt?: T; $lte?: T; $in?: T[] };
3415
3858
  export type VxilFilterRangeText = string | { $eq?: string; $ne?: string; $gt?: string; $gte?: string; $lt?: string; $lte?: string; $in?: string[]; $contains?: string };
3416
3859
  export type VxilFilterRangeDatetime = string | { $eq?: string; $ne?: string; $gt?: string; $gte?: string; $lt?: string; $lte?: string; $in?: string[]; $contains?: string };
3417
- export type VxilFilterJson = string | number | boolean | { $eq?: string | number | boolean; $in?: (string | number | boolean)[]; $contains?: string };
3860
+ export type VxilFilterJson = string | number | boolean | { $eq?: string | number | boolean; $in?: (string | number | boolean)[]; $contains?: string };${enumAliases}
3418
3861
 
3419
3862
  // Usage \u2014 narrow the client to THIS tenant's enabled features (a disabled feature
3420
3863
  // becomes a compile error):
@@ -3446,12 +3889,12 @@ var TOOLS = [
3446
3889
  {
3447
3890
  name: "notifications_send",
3448
3891
  feature: "notifications",
3449
- description: "Send a transactional email to an end-user by ID. Returns a delivery_id immediately (202); delivery is asynchronous. Templates: magic-link (data: url, expires_minutes), welcome (data: app_name, first_name?), transactional (data: subject, paragraph, cta_label?, cta_url?).",
3892
+ description: "Send a transactional email to an end-user by ID. Returns a delivery_id immediately (202); delivery is asynchronous. Templates: magic-link (data: url, expires_minutes), otp-code (data: code, expires_minutes), welcome (data: app_name, first_name?), transactional (data: subject, paragraph, cta_label?, cta_url?).",
3450
3893
  inputSchema: {
3451
3894
  type: "object",
3452
3895
  properties: {
3453
3896
  user_id: { type: "string", description: "Opaque end_user_id (your user id)." },
3454
- template: { type: "string", enum: ["magic-link", "welcome", "transactional"] },
3897
+ template: { type: "string", enum: ["magic-link", "otp-code", "welcome", "transactional"] },
3455
3898
  data: { type: "object", additionalProperties: true },
3456
3899
  locale: {
3457
3900
  type: "string",
@@ -3537,6 +3980,42 @@ var TOOLS = [
3537
3980
  path: "/v1/notifications/deliveries",
3538
3981
  queryArgs: ["user_id", "status", "engagement", "limit"]
3539
3982
  },
3983
+ {
3984
+ name: "notifications_get_preferences",
3985
+ feature: "notifications",
3986
+ description: "Read one end-user's notification mutes. Returns { preferences: [{ user_id, template_id, muted, effective, updated_at }], count } \u2014 the all-templates row (template_id null) first. NO row means NOT muted. MUTE WINS: the all-templates row SHADOWS a per-template muted:false, and `effective` is what enforcement actually does with that row (render `effective`, not `muted`). A mute suppresses the user on EVERY channel before the send leaves vxil (the delivery is recorded with status 'suppressed' and last_error_code 'muted'), except auth mail (magic-link, otp-code), which is never muteable. Read-only.",
3987
+ inputSchema: {
3988
+ type: "object",
3989
+ properties: {
3990
+ user_id: { type: "string", description: "Opaque end_user_id (your user id)." }
3991
+ },
3992
+ required: ["user_id"]
3993
+ },
3994
+ method: "GET",
3995
+ path: "/v1/notifications/preferences",
3996
+ queryArgs: ["user_id"]
3997
+ },
3998
+ {
3999
+ name: "notifications_set_preference",
4000
+ feature: "notifications",
4001
+ description: "Mute or un-mute an end-user's notifications. OMIT template_id to mute every MUTEABLE template for that user (welcome, transactional); pass one of those ids to mute just that one. Auth mail (magic-link, otp-code) is NEVER muteable \u2014 passing it is a 422, and the all-templates row does not cover it, so a user can never mute itself out of sign-in. MUTE WINS: the all-templates row shadows a per-template muted:false, so clear it before re-enabling one template. Idempotent upsert \u2014 there is no delete route, muted:false is the un-mute. 404 if the user id has no live record. Needs notifications:send (a server key writing another principal's row).",
4002
+ inputSchema: {
4003
+ type: "object",
4004
+ properties: {
4005
+ user_id: { type: "string", description: "Opaque end_user_id (your user id)." },
4006
+ template_id: {
4007
+ type: "string",
4008
+ // MUTEABLE ids only — auth mail is rejected by the route (422).
4009
+ enum: ["welcome", "transactional"],
4010
+ description: "Omit for the ALL-templates row (muteable templates only)."
4011
+ },
4012
+ muted: { type: "boolean" }
4013
+ },
4014
+ required: ["user_id", "muted"]
4015
+ },
4016
+ method: "PUT",
4017
+ path: "/v1/notifications/preferences"
4018
+ },
3540
4019
  {
3541
4020
  name: "notifications_campaign_run",
3542
4021
  feature: "notifications",
@@ -3997,7 +4476,7 @@ var TOOLS = [
3997
4476
  {
3998
4477
  name: "cms_query_items",
3999
4478
  feature: "cms",
4000
- 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). count=true returns {count} instead of a page (filter only \u2014 no sort/limit).',
4479
+ 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).',
4001
4480
  inputSchema: {
4002
4481
  type: "object",
4003
4482
  properties: {
@@ -4005,13 +4484,17 @@ var TOOLS = [
4005
4484
  filter: { type: "string", description: "JSON filter object as a string" },
4006
4485
  sort: { type: "string" },
4007
4486
  limit: { type: "number", default: 25 },
4487
+ $expand: {
4488
+ type: "string",
4489
+ description: "comma-separated relation/file field names to inline (bounded depth; see cms_list_collections for relation_to). Cannot be combined with count=true."
4490
+ },
4008
4491
  count: { type: "boolean", description: "true \u2192 return { count } only (no items/paging)" }
4009
4492
  },
4010
4493
  required: ["collection"]
4011
4494
  },
4012
4495
  method: "GET",
4013
4496
  path: (a) => `/v1/cms/items/${encodeURIComponent(String(a.collection))}`,
4014
- queryArgs: ["filter", "sort", "limit", "count"]
4497
+ queryArgs: ["filter", "sort", "limit", "$expand", "count"]
4015
4498
  },
4016
4499
  {
4017
4500
  name: "cms_inc_item",
@@ -4575,6 +5058,22 @@ var TOOLS = [
4575
5058
  maxItems: 8,
4576
5059
  description: "Vision inputs: a public https URL, a data:image/...;base64 URL, or file:<object_id> (a files object). Provider must support the 'vision' capability."
4577
5060
  },
5061
+ documents: {
5062
+ type: "array",
5063
+ items: { type: "string" },
5064
+ maxItems: 8,
5065
+ description: "Document inputs (pdf / plain text): a public https URL, a data:application/pdf;base64 URL, or file:<object_id>. Fetched and inlined server-side. Provider must support the 'documents' capability (anthropic)."
5066
+ },
5067
+ cache: {
5068
+ type: "object",
5069
+ additionalProperties: false,
5070
+ properties: {
5071
+ system: { type: "boolean" },
5072
+ messages: { type: "boolean" },
5073
+ ttl: { type: "string", enum: ["5m", "1h"] }
5074
+ },
5075
+ description: "Prompt-cache breakpoints (anthropic; ignored elsewhere): system caches the system prompt, messages caches the transcript prefix, ttl picks 5m (default) or 1h. A cache hit only lowers input_tokens."
5076
+ },
4578
5077
  user_id: { type: "string", description: "Opaque end_user_id for per-user usage metering." }
4579
5078
  }
4580
5079
  },
@@ -4740,7 +5239,7 @@ var TOOLS = [
4740
5239
  {
4741
5240
  name: "payments_list_webhook_events",
4742
5241
  feature: "payments",
4743
- description: "List provider webhook deliveries from the event log, newest-first: outcome (received|processed|error|sig_failed|parse_failed|reprocessed|ignored|unowned|rejected_environment), the provider-reported environment (production|sandbox), signature status, error, and timestamps per delivery. Use it to diagnose billing sync issues (e.g. an unmapped price or product \u2192 outcome error, a misconfigured webhook secret producing sig_failed rows, sandbox deliveries landing as rejected_environment). Read-only \u2014 needs payments:read. Payloads are detail-only (console surface).",
5242
+ description: "List provider webhook deliveries from the event log, newest-first: outcome (received|processed|error|sig_failed|parse_failed|reprocessed|ignored|unowned|rejected_environment), the provider-reported environment (production|sandbox), signature status, error, and timestamps per delivery. Use it to diagnose billing sync issues (e.g. an unmapped price or product \u2192 outcome error, a misconfigured webhook secret producing sig_failed rows, sandbox deliveries landing as rejected_environment). An unmapped one-off product lands `error` unless you set ledger.unmappedProduct:'ignore', which lands it `ignored` instead. Read-only \u2014 needs payments:read. Server-side key only (403 server_only on a thin-client/end-user key) \u2014 the log is tenant-wide and carries provider payloads. Payloads are detail-only (console surface).",
4744
5243
  inputSchema: {
4745
5244
  type: "object",
4746
5245
  properties: {
@@ -4768,7 +5267,7 @@ var TOOLS = [
4768
5267
  // Substrate tool (no `feature` — always-on like users_upsert): usage is a
4769
5268
  // platform property, not a feature you enable.
4770
5269
  name: "usage_current",
4771
- description: "Current-month usage for this project: requests used, the plan's included quota, remaining (null when the plan has no fixed cap), and month-to-date per-feature metered detail (may lag \u2014 it is aggregated periodically). Read-only. Call this before a bulk run to self-check remaining quota: the Free tier is hard-capped (429 quota_exceeded when exhausted); paid tiers are never cut off mid-month. Needs usage:read or features:read.",
5270
+ description: "Current-month usage for this project: requests used, the plan's included quota, remaining (null when the plan has no fixed cap), month-to-date per-feature metered detail (may lag \u2014 it is aggregated periodically), and `routes` \u2014 the same request count split by the surface that served it (the resolved upstream; /v1/fn/* reports as 'fn-invoke', busiest first, observational only). Read-only. Call this before a bulk run to self-check remaining quota: the Free tier is hard-capped (429 quota_exceeded when exhausted); paid tiers are never cut off mid-month. Needs usage:read or features:read.",
4772
5271
  inputSchema: { type: "object", properties: {} },
4773
5272
  method: "GET",
4774
5273
  path: "/v1/usage"
@@ -4828,12 +5327,13 @@ var TOOLS = [
4828
5327
  // Substrate meta-tool (no `feature` — always-on, subject to the caller's
4829
5328
  // scopes). Maps to POST /v1/plan (features:read). Advisory: proposes, never applies.
4830
5329
  name: "plan_backend",
4831
- description: 'Describe an app in plain English and get an ADVISORY vxil plan: which features to enable, a materialized config draft, and which truly-unique logic needs a tenant function. Read-only \u2014 it proposes a plan; you review and apply it via config push. e.g. "a marketplace where sellers list products and buyers leave reviews, emailing both on a sale".',
5330
+ description: 'Describe an app in plain English and get an ADVISORY vxil plan: which features to enable, a materialized config draft, and which truly-unique logic needs a tenant function. Read-only \u2014 it proposes a plan; you review and apply it via config push. e.g. "a marketplace where sellers list products and buyers leave reviews, emailing both on a sale". The response also carries an architecture brief: the platform rules this plan implies (identity, data, money path, background work, limits) and the older patterns they replace.',
4832
5331
  inputSchema: {
4833
5332
  type: "object",
4834
5333
  properties: {
4835
5334
  description: { type: "string", description: "Plain-English description of the app you want to build." },
4836
- name: { type: "string", description: "Optional app name for the plan." }
5335
+ name: { type: "string", description: "Optional app name for the plan." },
5336
+ brief: { type: "boolean", description: "Include the architecture brief (identity, data, money path, background work, limits, and the old patterns vxil replaces). Default true; pass false for the plan alone." }
4837
5337
  },
4838
5338
  required: ["description"]
4839
5339
  },
@@ -4934,18 +5434,18 @@ function buildMcpCatalog(input, tools = TOOLS) {
4934
5434
  }
4935
5435
 
4936
5436
  // src/store.ts
4937
- import { homedir } from "node:os";
5437
+ import { homedir as homedir2 } from "node:os";
4938
5438
  import { join as join2, resolve as resolve3 } from "node:path";
4939
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, chmodSync } from "node:fs";
5439
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2, chmodSync } from "node:fs";
4940
5440
  function credDir() {
4941
- return join2(homedir(), ".vxil");
5441
+ return join2(homedir2(), ".vxil");
4942
5442
  }
4943
5443
  function credFile() {
4944
5444
  return join2(credDir(), "credentials.json");
4945
5445
  }
4946
5446
  function loadCredentials() {
4947
5447
  try {
4948
- return JSON.parse(readFileSync2(credFile(), "utf8"));
5448
+ return JSON.parse(readFileSync3(credFile(), "utf8"));
4949
5449
  } catch {
4950
5450
  return {};
4951
5451
  }
@@ -4963,7 +5463,7 @@ function projectFile(cwd = process.cwd()) {
4963
5463
  }
4964
5464
  function loadProject(cwd = process.cwd()) {
4965
5465
  try {
4966
- return JSON.parse(readFileSync2(projectFile(cwd), "utf8"));
5466
+ return JSON.parse(readFileSync3(projectFile(cwd), "utf8"));
4967
5467
  } catch {
4968
5468
  return null;
4969
5469
  }
@@ -4999,7 +5499,15 @@ function bindNamedSlot(existing, name, slot) {
4999
5499
  );
5000
5500
  }
5001
5501
  if (name === "dev") {
5002
- return { ...existing, dev: { tenant_id: slot.tenant_id, slug: slot.slug, edge_url: slot.edge_url } };
5502
+ return {
5503
+ ...existing,
5504
+ dev: {
5505
+ tenant_id: slot.tenant_id,
5506
+ slug: slot.slug,
5507
+ edge_url: slot.edge_url,
5508
+ ...slot.expires_at ? { expires_at: slot.expires_at } : {}
5509
+ }
5510
+ };
5003
5511
  }
5004
5512
  const { expires_at: _x, ...permanent } = slot;
5005
5513
  void _x;
@@ -5265,6 +5773,28 @@ function driftExitCode(r) {
5265
5773
  if (r.errors > 0) return 2;
5266
5774
  return r.drift > 0 ? 1 : 0;
5267
5775
  }
5776
+ function newComparedCounts(opts = {}) {
5777
+ const base = { features: 0, collections: 0, functions: 0, secrets: 0 };
5778
+ return opts.apiState ? { ...base, policies: 0, subscriptions: 0, campaigns: 0 } : base;
5779
+ }
5780
+ var COMPARED_LABELS = [
5781
+ ["features", "feature"],
5782
+ ["collections", "collection"],
5783
+ ["functions", "function"],
5784
+ ["secrets", "secret ref"],
5785
+ ["policies", "policy"],
5786
+ ["subscriptions", "subscription"],
5787
+ ["campaigns", "campaign"]
5788
+ ];
5789
+ function formatCompared(c) {
5790
+ const parts = [];
5791
+ for (const [key, label] of COMPARED_LABELS) {
5792
+ const v = c[key];
5793
+ if (v === void 0) continue;
5794
+ parts.push(label === "policy" ? `${v} policy(ies)` : `${v} ${label}(s)`);
5795
+ }
5796
+ return parts.join(" \xB7 ");
5797
+ }
5268
5798
 
5269
5799
  // src/doctor.ts
5270
5800
  var SECRETS_ADVISORY_NOTE = "advisory only: a config push with an unstored `secret:<name>` ref is a WARNING, never a 422 \u2014 vxil's promotion order sets production secrets AFTER the config push (`vxil secrets set <feature>/<name>`); doctor never deletes a stored secret";
@@ -5307,6 +5837,17 @@ function notificationsProviderChecks(manifest) {
5307
5837
  }
5308
5838
  return [{ name: "notifications provider", ok: true, warn: true, detail: MOCK_PROVIDER_NOTE }];
5309
5839
  }
5840
+ var PAYMENTS_WEBHOOK_SECRET_NOTE = "payments provider is real but no per-tenant webhook secret is declared: every inbound provider delivery will be rejected 401 bad_signature and recorded as a sig_failed row (those rows can never be reprocessed, so the money events behind them are lost). There is NO platform-wide fallback. Declare <provider>.webhookSecretRef in your payments config and store the value with `vxil secrets set payments/<name>`";
5841
+ function paymentsWebhookSecretChecks(manifest) {
5842
+ if (!manifest || manifest.enabled === false) return [];
5843
+ const provider = String(manifest.provider ?? "");
5844
+ if (provider === "mock" || provider === "") return [];
5845
+ const declared = provider === "paypal" ? !!manifest.paypal?.webhookId && !!manifest.paypal?.clientIdRef && !!manifest.paypal?.secretRef : provider === "stripe" ? !!manifest.stripe?.webhookSecretRef : provider === "paddle" ? !!manifest.paddle?.webhookSecretRef : provider === "revenuecat" ? !!manifest.revenuecat?.webhookSecretRef : true;
5846
+ if (declared) {
5847
+ return [{ name: "payments webhook secret", ok: true, detail: `${provider} \xB7 webhook credential declared` }];
5848
+ }
5849
+ return [{ name: "payments webhook secret", ok: true, warn: true, detail: `${provider}: ${PAYMENTS_WEBHOOK_SECRET_NOTE}` }];
5850
+ }
5310
5851
  function sha12Of(scriptRef) {
5311
5852
  if (!scriptRef) return void 0;
5312
5853
  const m = /-([0-9a-f]{12})$/.exec(scriptRef);
@@ -5353,6 +5894,36 @@ function functionChecks(rows) {
5353
5894
  ...r.verdict === "local-changed" || r.verdict === "not-deployed" || r.verdict === "remote-only" || r.verdict === "served-unpublished" ? { warn: true } : {}
5354
5895
  }));
5355
5896
  }
5897
+ function devSlotExpiryCheck(dev, now) {
5898
+ if (!dev) return null;
5899
+ if (!dev.expires_at) {
5900
+ return {
5901
+ name: "dev slot expiry",
5902
+ ok: true,
5903
+ warn: true,
5904
+ detail: `unknown for '${dev.slug}' \u2014 run \`vxil dev up\` to refresh it (a slot bound with \`vxil link --as dev\`, or written before the CLI tracked TTLs, carries none)`
5905
+ };
5906
+ }
5907
+ const ms = new Date(dev.expires_at).getTime();
5908
+ if (!Number.isFinite(ms)) {
5909
+ return { name: "dev slot expiry", ok: true, warn: true, detail: `unreadable expiry '${dev.expires_at}' on '${dev.slug}' \u2014 run \`vxil dev up\`` };
5910
+ }
5911
+ const hours = Math.round((ms - now.getTime()) / 36e5);
5912
+ if (hours <= 0) {
5913
+ return {
5914
+ name: "dev slot expiry",
5915
+ ok: true,
5916
+ warn: true,
5917
+ detail: `'${dev.slug}' EXPIRED ${dev.expires_at} (${Math.abs(hours)}h ago) \u2014 the nightly reaper deletes it; run \`vxil dev up\``
5918
+ };
5919
+ }
5920
+ return {
5921
+ name: "dev slot expiry",
5922
+ ok: true,
5923
+ ...hours < 72 ? { warn: true } : {},
5924
+ detail: `'${dev.slug}' expires ${dev.expires_at} (in ${hours}h)` + (hours < 72 ? " \u2014 run `vxil dev up` to extend" : "")
5925
+ };
5926
+ }
5356
5927
 
5357
5928
  // src/link.ts
5358
5929
  function data(r) {
@@ -5441,6 +6012,37 @@ async function devDown(dash, cookie, dev) {
5441
6012
  `dev down failed (${e?.code ?? r.status}${e?.message ? ` \u2014 ${e.message}` : ""})`
5442
6013
  );
5443
6014
  }
6015
+ async function devExtend(dash, cookie, dev, ttlHours) {
6016
+ const r = await dash(
6017
+ "POST",
6018
+ `/dashboard/tenants/${dev.tenant_id}/extend`,
6019
+ ttlHours === void 0 ? {} : { ttl_hours: ttlHours },
6020
+ cookie
6021
+ );
6022
+ if (r.status === 200) {
6023
+ const d = r.json.data ?? r.json;
6024
+ return { extended: true, ...d.expires_at ? { expires_at: d.expires_at } : {} };
6025
+ }
6026
+ if (r.status === 404) return { gone: true };
6027
+ const e = r.json.error;
6028
+ if (r.status === 409 && e?.code === "not_a_dev_tenant") return { notDev: true };
6029
+ throw new Error(
6030
+ `dev tenant extend failed (${e?.code ?? r.status}${e?.message ? ` - ${e.message}` : ""})`
6031
+ );
6032
+ }
6033
+ var TTL_MIN_HOURS = 1;
6034
+ var TTL_MAX_HOURS = 720;
6035
+ function parseTtlFlag(raw, fallback) {
6036
+ if (raw === void 0) return { hours: fallback };
6037
+ const n = Number(raw);
6038
+ if (!Number.isInteger(n) || n < TTL_MIN_HOURS || n > TTL_MAX_HOURS) {
6039
+ return { error: `--ttl '${raw}': expected a whole number of HOURS, ${TTL_MIN_HOURS}..${TTL_MAX_HOURS} (default ${fallback})` };
6040
+ }
6041
+ return { hours: n };
6042
+ }
6043
+ function haveDashSession(creds, email, password) {
6044
+ return Boolean(creds.session) || Boolean(email && password);
6045
+ }
5444
6046
 
5445
6047
  // src/envPull.ts
5446
6048
  var MANAGED_HEADER = "# vxil (managed by `vxil env pull`)";
@@ -5502,6 +6104,13 @@ function pickBranchSlot(binding, name, now, hasKey) {
5502
6104
  if (!hasKey(slot.slug)) return { create: true };
5503
6105
  return { reuse: slot };
5504
6106
  }
6107
+ function pickDevSlot(binding, now, hasKey) {
6108
+ const slot = binding?.dev;
6109
+ if (!slot) return { create: true };
6110
+ if (slot.expires_at && new Date(slot.expires_at).getTime() <= now.getTime()) return { create: true };
6111
+ if (!hasKey(slot.slug)) return { create: true };
6112
+ return { reuse: slot };
6113
+ }
5505
6114
  async function createBranchTenant(dash, opts) {
5506
6115
  const r = await dash("POST", "/dashboard/quickstart", {
5507
6116
  email: opts.email,
@@ -5528,6 +6137,159 @@ async function createBranchTenant(dash, opts) {
5528
6137
  };
5529
6138
  }
5530
6139
 
6140
+ // src/promotionGate.ts
6141
+ var NON_PRODUCTION_ENV_LABELS = /* @__PURE__ */ new Set([
6142
+ "staging",
6143
+ "stage",
6144
+ "dev",
6145
+ "development",
6146
+ "test",
6147
+ "qa",
6148
+ "preview",
6149
+ "sandbox",
6150
+ "local"
6151
+ ]);
6152
+ function normalizeLabel(label) {
6153
+ return label.trim().toLowerCase();
6154
+ }
6155
+ function isNonProductionLabel(label) {
6156
+ return NON_PRODUCTION_ENV_LABELS.has(normalizeLabel(label));
6157
+ }
6158
+ function isDevSlot(t) {
6159
+ return t.slot === "dev" || t.slot === "named" && t.selector.kind === "named" && t.selector.name === "dev";
6160
+ }
6161
+ function isProductionTarget(t) {
6162
+ if (isDevSlot(t)) return false;
6163
+ if (normalizeLabel(t.envLabel) === "production") return true;
6164
+ return envLabelFor(t.baseUrl) === "production" && !isNonProductionLabel(t.envLabel);
6165
+ }
6166
+ function hostOf(baseUrl) {
6167
+ try {
6168
+ return new URL(baseUrl).host;
6169
+ } catch {
6170
+ return baseUrl;
6171
+ }
6172
+ }
6173
+ function unrecognizedLabelNotice(t) {
6174
+ if (isDevSlot(t)) return null;
6175
+ if (normalizeLabel(t.envLabel) === "production") return null;
6176
+ if (envLabelFor(t.baseUrl) !== "production") return null;
6177
+ if (isNonProductionLabel(t.envLabel)) return null;
6178
+ return `env label '${t.envLabel}' is not a recognized environment and the host is ${hostOf(t.baseUrl)} \u2014 treating this target as PRODUCTION (use --env staging|dev|test|qa|preview|sandbox|local to say otherwise)`;
6179
+ }
6180
+ function envLabelWarning(label, baseUrl) {
6181
+ const l = normalizeLabel(label);
6182
+ const host = hostOf(baseUrl);
6183
+ if (envLabelFor(baseUrl) === "production") {
6184
+ if (l === "production") return null;
6185
+ if (isNonProductionLabel(l)) {
6186
+ return `slot labelled '${label}' on the PRODUCTION host ${host} \u2014 the push promotion gate will NOT fire for it (the documented staging-twin pattern; re-link with --env production if this IS a production tenant)`;
6187
+ }
6188
+ return `env label '${label}' is not a recognized environment \u2014 on the PRODUCTION host ${host} it is treated as PRODUCTION (the push promotion gate WILL fire). Use production|staging|dev|test|qa|preview|sandbox|local.`;
6189
+ }
6190
+ if (l === "production") {
6191
+ return `slot labelled 'production' on the non-production host ${host} \u2014 the push promotion gate WILL fire for it`;
6192
+ }
6193
+ return null;
6194
+ }
6195
+ function isPlainObject2(v) {
6196
+ return v !== null && typeof v === "object" && !Array.isArray(v);
6197
+ }
6198
+ function leafAt(manifest, dotted) {
6199
+ let cur = manifest;
6200
+ for (const seg of dotted.split(".")) {
6201
+ if (!isPlainObject2(cur)) return void 0;
6202
+ cur = cur[seg];
6203
+ }
6204
+ return cur;
6205
+ }
6206
+ function mockFeatures(cfg) {
6207
+ return Object.entries(cfg.features ?? {}).filter(([, conf]) => {
6208
+ if (!isPlainObject2(conf)) return false;
6209
+ const c = conf;
6210
+ return c.provider === "mock" || c.defaultProvider === "mock" || (isPlainObject2(c.embed) ? c.embed.provider === "mock" : false);
6211
+ }).map(([f]) => f);
6212
+ }
6213
+ var SANDBOX_LEAVES = [
6214
+ { feature: "payments", path: "paddle.sandbox" },
6215
+ { feature: "payments", path: "paypal.sandbox" },
6216
+ { feature: "payments", path: "revenuecat.acceptSandbox" }
6217
+ ];
6218
+ function sandboxFeatures(cfg) {
6219
+ const out = [];
6220
+ for (const leaf of SANDBOX_LEAVES) {
6221
+ const manifest = (cfg.features ?? {})[leaf.feature];
6222
+ if (leafAt(manifest, leaf.path) === true) out.push(`${leaf.feature}.${leaf.path}`);
6223
+ }
6224
+ return out;
6225
+ }
6226
+ function isDevHost(host) {
6227
+ const h = host.toLowerCase().replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
6228
+ if (h === "localhost" || h.endsWith(".localhost")) return true;
6229
+ if (h === "127.0.0.1" || h.startsWith("127.")) return true;
6230
+ if (h === "::1") return true;
6231
+ if (h === "local" || h.endsWith(".local")) return true;
6232
+ if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
6233
+ if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
6234
+ if (/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
6235
+ return false;
6236
+ }
6237
+ function devRedirectOrigins(origins = []) {
6238
+ const out = [];
6239
+ for (const raw of origins) {
6240
+ if (typeof raw !== "string" || !raw) continue;
6241
+ if (/^http:\/\//i.test(raw)) {
6242
+ out.push(raw);
6243
+ continue;
6244
+ }
6245
+ const host = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/[/?#].*$/, "");
6246
+ if (host && isDevHost(host)) out.push(raw);
6247
+ }
6248
+ return out;
6249
+ }
6250
+ function redirectOrigins(cfg) {
6251
+ const v = leafAt((cfg.features ?? {}).auth, "security.allowedRedirectOrigins");
6252
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
6253
+ }
6254
+ var PROMOTION_OVERRIDE_FLAGS = {
6255
+ mock: "allow-mock-in-prod",
6256
+ sandbox: "allow-sandbox-in-prod",
6257
+ origins: "allow-dev-origins-in-prod"
6258
+ };
6259
+ function promotionRefusals(cfg, allowed) {
6260
+ const out = [];
6261
+ const mocks = mockFeatures(cfg);
6262
+ if (mocks.length && !allowed(PROMOTION_OVERRIDE_FLAGS.mock)) {
6263
+ out.push({
6264
+ kind: "mock",
6265
+ flag: PROMOTION_OVERRIDE_FLAGS.mock,
6266
+ message: `mock providers on [${mocks.join(", ")}] would ship to production (mock email/payments silently no-op). Set a real provider, or override with --${PROMOTION_OVERRIDE_FLAGS.mock}.`
6267
+ });
6268
+ }
6269
+ const sandboxes = sandboxFeatures(cfg);
6270
+ if (sandboxes.length && !allowed(PROMOTION_OVERRIDE_FLAGS.sandbox)) {
6271
+ out.push({
6272
+ kind: "sandbox",
6273
+ flag: PROMOTION_OVERRIDE_FLAGS.sandbox,
6274
+ 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}.`
6275
+ });
6276
+ }
6277
+ const devOrigins = devRedirectOrigins(redirectOrigins(cfg));
6278
+ if (devOrigins.length && !allowed(PROMOTION_OVERRIDE_FLAGS.origins)) {
6279
+ out.push({
6280
+ kind: "origins",
6281
+ flag: PROMOTION_OVERRIDE_FLAGS.origins,
6282
+ message: `auth.security.allowedRedirectOrigins contains non-production origin(s) [${devOrigins.join(", ")}] \u2014 a loopback / .local / http:// redirect target on a production auth tenant is a token-exfiltration footgun. Remove them, or override with --${PROMOTION_OVERRIDE_FLAGS.origins}.`
6283
+ });
6284
+ }
6285
+ return out;
6286
+ }
6287
+ function redirectOriginNotice(cfg) {
6288
+ const origins = redirectOrigins(cfg);
6289
+ if (!origins.length) return null;
6290
+ return `auth.security.allowedRedirectOrigins (${origins.length}): ${origins.join(", ")}`;
6291
+ }
6292
+
5531
6293
  // src/try.ts
5532
6294
  async function mintTry(fetchImpl, base, opts = {}) {
5533
6295
  const url = `${base.replace(/\/$/, "")}/v1/try`;
@@ -5649,11 +6411,11 @@ function formatSimulate(r) {
5649
6411
  }
5650
6412
 
5651
6413
  // src/migrate/paymentsSync.ts
5652
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "node:fs";
5653
- import { dirname as dirname3, resolve as resolve5 } from "node:path";
6414
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "node:fs";
6415
+ import { dirname as dirname4, resolve as resolve5 } from "node:path";
5654
6416
 
5655
6417
  // src/migrate/commands.ts
5656
- import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
6418
+ import { appendFileSync, existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
5657
6419
  import { basename as basename2, resolve as resolve4 } from "node:path";
5658
6420
 
5659
6421
  // src/migrate/inference.ts
@@ -5742,7 +6504,7 @@ function detectOwner(table, source, authTable, opts) {
5742
6504
  for (const p of source.rlsPolicies ?? []) {
5743
6505
  if (lastSeg(p.table) !== table.name) continue;
5744
6506
  const m = /auth\.uid\(\)\s*=\s*(?:[\w"]+\.)?"?([A-Za-z_]\w*)"?/.exec(p.definition) ?? /(?:[\w"]+\.)?"?([A-Za-z_]\w*)"?\s*=\s*auth\.uid\(\)/.exec(p.definition);
5745
- if (m && cols.has(m[1])) return { column: m[1], via: `RLS policy${p.name ? ` '${p.name}'` : ""}` };
6507
+ if (m && cols.has(m[1])) return { column: m[1], via: `row-level access rule${p.name ? ` '${p.name}'` : ""}` };
5746
6508
  }
5747
6509
  for (const fk of source.foreignKeys) {
5748
6510
  if (fk.childTable !== table.name || fk.childColumns.length !== 1) continue;
@@ -6081,7 +6843,7 @@ function inferMapping(source, opts = {}) {
6081
6843
  }
6082
6844
  if ((source.rlsPolicies?.length ?? 0) > 0) {
6083
6845
  residuals.push(
6084
- `R15: ${source.rlsPolicies.length} RLS policies are NOT recreated \u2014 they become per-collection ownerField + an end_user_required key + the X-Vxil-End-User principal (R6/R7); raw policies are listed in the plan for review`
6846
+ `R15: ${source.rlsPolicies.length} row-level access rules are NOT recreated \u2014 they become per-collection ownerField + an end_user_required key + the X-Vxil-End-User principal (R6/R7); raw policies are listed in the plan for review`
6085
6847
  );
6086
6848
  }
6087
6849
  if ((source.buckets?.length ?? 0) > 0) {
@@ -6135,8 +6897,8 @@ function topoOrder(cmsNames, fks, profileTable, warnings) {
6135
6897
  }
6136
6898
 
6137
6899
  // src/migrate/loaders/index.ts
6138
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
6139
- import { dirname as dirname2 } from "node:path";
6900
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
6901
+ import { dirname as dirname3 } from "node:path";
6140
6902
 
6141
6903
  // src/migrate/types.ts
6142
6904
  function newMigrateState() {
@@ -6949,10 +7711,10 @@ async function verifyMigration({ deps, plan, adapter, state }) {
6949
7711
 
6950
7712
  // src/migrate/loaders/index.ts
6951
7713
  function loadMigrateState(stateFile) {
6952
- if (!existsSync3(stateFile)) return newMigrateState();
7714
+ if (!existsSync4(stateFile)) return newMigrateState();
6953
7715
  let parsed;
6954
7716
  try {
6955
- parsed = JSON.parse(readFileSync3(stateFile, "utf8"));
7717
+ parsed = JSON.parse(readFileSync4(stateFile, "utf8"));
6956
7718
  } catch (e) {
6957
7719
  throw new Error(
6958
7720
  `migrate state ${stateFile} is not parseable JSON (${e.message.split("\n")[0]}) \u2014 fix or delete the file (deleting restarts the migration; loaded cms rows are still deduped only if the id-map inside it is preserved).`
@@ -6970,7 +7732,7 @@ function loadMigrateState(stateFile) {
6970
7732
  };
6971
7733
  }
6972
7734
  function saveMigrateState(stateFile, state) {
6973
- mkdirSync2(dirname2(stateFile), { recursive: true });
7735
+ mkdirSync2(dirname3(stateFile), { recursive: true });
6974
7736
  writeFileSync3(stateFile, JSON.stringify(state, null, 2) + "\n");
6975
7737
  }
6976
7738
  function guardReadOnly(api) {
@@ -7098,7 +7860,7 @@ function writeMigrateArtifact(path, content) {
7098
7860
  function ensureStateIgnored(cwd, log) {
7099
7861
  const entry = `${MIGRATE_DIR}/state.json`;
7100
7862
  const gi = resolve4(cwd, ".gitignore");
7101
- const existing = existsSync4(gi) ? readFileSync4(gi, "utf8") : "";
7863
+ const existing = existsSync5(gi) ? readFileSync5(gi, "utf8") : "";
7102
7864
  if (gitignoreCovers(existing, entry)) return;
7103
7865
  const sep2 = existing === "" || existing.endsWith("\n") ? "" : "\n";
7104
7866
  appendFileSync(gi, `${sep2}
@@ -7126,7 +7888,7 @@ var STANDING_CAVEATS = [
7126
7888
  "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.",
7127
7889
  "Push device tokens: no vxil landing (no push channel) \u2014 clients re-enroll post-cutover.",
7128
7890
  "Subscriptions / entitlements / tier: NEVER seeded (provider-derived truth) \u2014 rebuild from your live provider webhooks after cutover.",
7129
- "DB triggers / RPCs / pg_cron / RLS: 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.",
7891
+ "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.",
7130
7892
  "File objects: re-keyed (new object_id; source paths/URLs not preserved) \u2014 mint downloadUrl at read time, never persist it."
7131
7893
  ];
7132
7894
  function emitResidualsMd(plan, sourceLabel) {
@@ -7196,12 +7958,12 @@ async function runMigratePlan({ adapter, cwd, log, sourceLabel, infer }) {
7196
7958
  }
7197
7959
  function readPlanFile(cwd) {
7198
7960
  const p = migratePaths(cwd);
7199
- if (!existsSync4(p.planFile)) {
7961
+ if (!existsSync5(p.planFile)) {
7200
7962
  throw new Error(`no ${MIGRATE_DIR}/plan.json \u2014 run \`vxil migrate plan --from <provider> \u2026\` first.`);
7201
7963
  }
7202
7964
  let parsed;
7203
7965
  try {
7204
- parsed = JSON.parse(readFileSync4(p.planFile, "utf8"));
7966
+ parsed = JSON.parse(readFileSync5(p.planFile, "utf8"));
7205
7967
  } catch (e) {
7206
7968
  throw new Error(`${MIGRATE_DIR}/plan.json is not parseable JSON (${e.message.split("\n")[0]}) \u2014 re-run \`vxil migrate plan\`.`);
7207
7969
  }
@@ -7219,10 +7981,10 @@ function readPlanFile(cwd) {
7219
7981
  }
7220
7982
  function readPaymentsSpecs(cwd) {
7221
7983
  const p = migratePaths(cwd);
7222
- if (!existsSync4(p.paymentsFile)) return void 0;
7984
+ if (!existsSync5(p.paymentsFile)) return void 0;
7223
7985
  let parsed;
7224
7986
  try {
7225
- parsed = JSON.parse(readFileSync4(p.paymentsFile, "utf8"));
7987
+ parsed = JSON.parse(readFileSync5(p.paymentsFile, "utf8"));
7226
7988
  } catch (e) {
7227
7989
  throw new Error(`${MIGRATE_DIR}/payments.json is not parseable JSON (${e.message.split("\n")[0]})`);
7228
7990
  }
@@ -7304,10 +8066,10 @@ function paymentsSyncStatePath(cwd) {
7304
8066
  return resolve5(cwd, MIGRATE_DIR, "payments-sync.state.json");
7305
8067
  }
7306
8068
  function loadPaymentsSyncState(path, provider) {
7307
- if (!existsSync5(path)) return { version: 1, provider, done: {} };
8069
+ if (!existsSync6(path)) return { version: 1, provider, done: {} };
7308
8070
  let parsed;
7309
8071
  try {
7310
- parsed = JSON.parse(readFileSync5(path, "utf8"));
8072
+ parsed = JSON.parse(readFileSync6(path, "utf8"));
7311
8073
  } catch (e) {
7312
8074
  throw new Error(`${path} is not parseable JSON (${e.message.split("\n")[0]}) \u2014 fix or delete it (deleting re-syncs every customer; the server fold is idempotent).`);
7313
8075
  }
@@ -7319,7 +8081,7 @@ function loadPaymentsSyncState(path, provider) {
7319
8081
  return { version: 1, provider, done: s.done ?? {}, ...s.usersCursor !== void 0 ? { usersCursor: s.usersCursor } : {} };
7320
8082
  }
7321
8083
  function savePaymentsSyncState(path, state) {
7322
- mkdirSync4(dirname3(path), { recursive: true });
8084
+ mkdirSync4(dirname4(path), { recursive: true });
7323
8085
  writeFileSync5(path, JSON.stringify(state, null, 2) + "\n");
7324
8086
  }
7325
8087
  function parseCustomersCsv(text) {
@@ -8000,7 +8762,7 @@ export default defineConfig({
8000
8762
  "readme": '# AI Journal template\n\nAn AI-powered private journal \u2014 declared end-to-end in one typed `vxil.config.ts`. Every saved entry is\nenriched by a function (one-sentence summary + one-word mood via the `ai` feature) and indexed for retrieval,\nso you can literally *ask your journal* and get grounded, cited answers back.\n\n**What it provisions:**\n- `entries` \u2014 title, body, AI-derived `mood`/`summary`, `written_at`, tags, and `user_id` as the **owner field**\n (a verified end-user only sees their own journal). Lane-A hooks require a title and stamp `written_at`.\n- Features: `cms` + `ai` + `rag` + `vector-search` (rag\'s retrieval leg) + `notifications` + `functions`.\n- Functions: `on-entry-written` (cmsHook: enrich + ingest), `ask-journal` (http: grounded Q&A),\n `weekly-digest` (cron: Monday digest per writer).\n\n**Apply it:**\n\n```bash\nvxil init --template ai-journal\nvxil quickstart --invite <code> # only when the email is new (or `vxil link` an existing tenant)\nvxil push\nvxil gen\n# one-time: create the retrieval index (dimensions/embedder come from config defaults)\ncurl -X POST https://api.vxil.com/v1/search/collections \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"collection":"journal"}\'\n```\n\n**What to learn from this:**\n1. **AI enrichment on write** \u2014 a `cmsHook` function re-fetches the entry by `item_id` (never trusts inline\n fields), calls `POST /v1/ai/generate` (raw-prompt mode), PATCHes `summary`/`mood` back, and latches on\n `summary` so its own write-back never re-enriches.\n2. **Retrieval-augmented "ask your journal"** \u2014 `POST /v1/rag/answer` retrieves top-k from the `journal`\n index and returns the answer *with citations* (`doc_id` = the entry\'s `item_id`); the prompt stays yours.\n3. **BYO AI key via encrypted secrets** \u2014 config carries only the reference (`providers.openaiKeyRef`);\n `vxil secrets set ai/openai_key` stores the value envelope-encrypted, then flip `defaultProvider`/`model`.\n Until then the deterministic `mock` provider (and mock embedder) run the whole loop keyless.\n\n```bash\ncurl -X POST https://api.vxil.com/v1/fn/ask-journal \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"question":"what made me happy this month?"}\'\n```\n\n**Go deeper:** vxil.com/docs/guide/06-feature-catalog (ai, rag) \xB7 vxil.com/docs/guide/08-running-your-code-functions \xB7\nvxil.com/docs/guide/07-validation-and-hooks \xB7 vxil.com/docs/guide/04-data-with-cms (owner-scope) \xB7 `examples/ecommerce/` (a bigger functions saga).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
8001
8763
  "functions": {
8002
8764
  "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\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n // http-trigger: the caller's JSON body lands under `payload`.\n payload?: { question?: string; user_id?: string };\n}\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",
8003
- "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\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\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 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",
8765
+ "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\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\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",
8004
8766
  "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\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n idempotency_key?: string;\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, 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"
8005
8767
  }
8006
8768
  },
@@ -8008,7 +8770,7 @@ export default defineConfig({
8008
8770
  "id": "agent-desk",
8009
8771
  "title": "Agent Desk (mcp \xB7 copilot \xB7 classify \xB7 judge \xB7 rag)",
8010
8772
  "vertical": "ai",
8011
- "summary": "The whole AI half on a deliberately small support desk \u2014 forced-label classification on every new ticket, a grounded and cited draft scored by a second judging pass, a knowledge index kept in step with a cms collection, an in-app assistant whose writes are proposed and human-confirmed, the same backend exposed as a narrowed MCP tool set for a least-privilege agent key, and a capability probe that reads the 170-event catalog so an agent can discover what it is able to react to.",
8773
+ "summary": "The whole AI half on a deliberately small support desk \u2014 forced-label classification on every new ticket, a grounded and cited draft scored by a second judging pass, a knowledge index kept in step with a cms collection, an in-app assistant whose writes are proposed and human-confirmed, the same backend exposed as a narrowed MCP tool set for a least-privilege agent key, and a capability probe that reads the platform event catalog so an agent can discover what it is able to react to.",
8012
8774
  "collections": [
8013
8775
  "tickets",
8014
8776
  "kb"
@@ -8031,8 +8793,8 @@ export default defineConfig({
8031
8793
  "readme": '# Agent Desk (ai)\n\nThe whole AI half of vxil on a deliberately small support desk \u2014 two collections, three functions,\nand one idea per feature. If you have been trying to work out where `ai`, `rag`, `vector-search`,\n`copilot` and `mcp` differ, this is the blueprint that answers it by making each one do exactly its\nown job.\n\n```bash\nvxil init --template agent-desk\nvxil quickstart # or `vxil link <slug>`\nprintf \'%s\' "$READ_KEY" | vxil secrets set functions/vxil_read_key\nvxil push # collections + the three functions\n```\n\nEverything runs on the deterministic **`mock`** model provider, so the walkthrough below is\nreproducible with no provider account and no spend. Swapping in a real model is one config line and\none secret \u2014 nothing else in this blueprint changes.\n\n`vxil_read_key` is a key **of this same backend** carrying only `features:read` and `webhooks:read`;\nthe capability probe uses it (dashboard \u2192 API keys \u2192 create, tick those two and nothing else).\n\n## One idea per feature\n\n| Feature | The one thing it does here | Why it is not one of the others |\n|---|---|---|\n| `ai` classify | pick exactly one label from a fixed set | a chat prompt can return a paragraph; a classifier cannot |\n| `ai` judge | score a draft as an integer on a fixed scale | the model that writes is not the authority on whether the writing is good |\n| `vector-search` | hold the knowledge index, synced from `kb` | retrieval, not generation \u2014 no prompt lives here |\n| `rag` | answer **only** from what was retrieved, with citations | the pipeline; the *prompt* is a template you own |\n| `copilot` | the in-app assistant: propose a write, a human confirms | it is a composition over the four above, not a fifth model |\n| `mcp` | the same backend, as tools, narrowed per key | an agent\'s *interface*, not an agent |\n| `functions` | the deterministic steps around the model calls | the parts that must not be creative |\n\n## Prompts are yours, not config\n\n`rag.defaultTemplate: \'support-answer\'` names a **prompt template**, which is a versioned row you\ncreate over the API \u2014 deliberately not a config leaf, because a prompt is the part of the product\nyou iterate on hourly. Create it before the first answer:\n\n```bash\nvxil api POST /v1/ai/templates --data \'{\n "template": "support-answer",\n "system": "You are a support agent. Answer ONLY from the context. If the context does not contain the answer, say you do not know.",\n "user": "Context:\\n{{context}}\\n\\nCustomer question:\\n{{query}}\\n\\nWrite a short, direct reply."\n}\'\n# 201 { "data": { "template": "support-answer", "version": 1 } }\n```\n\nRe-POST the same name and you get version 2 \u2014 old versions stay pinnable. `{{query}}` and\n`{{context}}` are what the retrieval step fills in. **A grounded answer with no template is a 404**,\nso this is step zero, not an optional flourish.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `ai:read ai:write rag:read rag:write vector-search:read\nvector-search:write cms:read cms:write copilot:read copilot:write webhooks:read functions:invoke\nfeatures:read`.\n\n**1. The index.** The `kb` cms collection is what you author in; the `kb` vector collection is what\nretrieval reads. Create the index, then push an article into it:\n\n```bash\nvxil api POST /v1/search/collections --data \'{"collection":"kb","dimensions":1536}\'\n# 201 { "data": { "collection": "kb", "dimensions": 1536, "backend": "\u2026" } }\n# (`vector-search.sync` also reconciles one scheduled job per entry \u2014 you can see it in\n# `GET /v1/jobs/schedules` as `vs-sync:cms~kb~kb`, on the cron you declared.)\n\nvxil api POST /v1/rag/ingest/kb --data \'{\n "doc_id": "how-refunds-work",\n "text": "A refund is issued to the original payment method within 14 days of purchase. Ask the customer for the order id, confirm the purchase date, then issue the refund from the billing screen. Refunds are not available after 14 days.",\n "metadata": { "topic": "billing" }\n}\'\n# 202 { "data": { "doc_id": "how-refunds-work", "status": "indexed", "chunks": 1, "embedding_tokens": \u2026 } }\n```\n\n`POST /v1/rag/ingest/{collection}` is a convenience: a key holding only `rag:write` can fill the\nindex without also holding a vector-search scope.\n\nYou do not have to remember to do that twice, though \u2014 `vector-search.sync` in `vxil.config.ts`\ndeclares the `kb` cms collection as a source, so published articles are embedded on a schedule and\nthe index never silently drifts from the content. The direct ingest above just saves you the wait.\n\n**2. Classification, on every new ticket.** Create one and watch the hook:\n\n```bash\nvxil api POST /v1/cms/items/tickets --data \'{"data":{"subject":"Billing: charged twice this month","requester":"u_ana","state":"open","body":"My card was charged twice on the 3rd. Can I get one of them back?"}}\'\n# 201 { "data": { "item_id": "itm_\u2026", \u2026 } }\n\n# a moment later\nvxil api GET /v1/cms/items/tickets/itm_\u2026\n# 200 \u2026 "data": { "subject": "Billing: charged twice this month", "category": "billing", "state": "open", \u2026 }\n```\n\n`triage-ticket` fired on the write, re-fetched the row (a hook delivery carries ids, not the\ndocument), and asked for a **forced-label verdict**:\n\n```bash\nvxil api POST /v1/ai/classify --data \'{"input":"Billing: charged twice this month","labels":["billing","bug","how_to","other"]}\'\n# 200 { "data": { "generation_id": "gen_\u2026", "label": "billing", "confidence": 0.9,\n# "rationale": "\u2026", "usage": { \u2026 }, "cached": false } }\n```\n\nThe label set is part of the request, so the answer is constrained to it by the schema \u2014 the model\ncannot invent a fifth category or reply with a sentence. The function is also idempotent by\ninspection: a ticket that already has a `category` is skipped, because hook delivery is\nat-least-once and a redelivery should not cost another model call.\n\n**3. A grounded, cited draft \u2014 and a second opinion on it.** Press the record\'s button:\n\n```bash\nvxil api POST /v1/cms/items/tickets/itm_\u2026/actions/draft_reply\n# 200 { "data": { "collection": "tickets", "item_id": "itm_\u2026", "action": "draft_reply",\n# "fn": "draft-reply",\n# "result": { "draft": "\u2026", "score": 10, "verdict": "pass",\n# "citations": [ { "chunk_id": "how-refunds-work#0",\n# "doc_id": "how-refunds-work", "score": 0.0164 } ],\n# "written": true } } }\n```\n\nTwo calls happened inside, and the split is the lesson:\n\n```bash\nvxil api POST /v1/rag/answer --data \'{"query":"My card was charged twice. Can I get one back?","collection":"kb","top_k":5,"stream":false}\'\n# 200 { "data": { "answer": "\u2026",\n# "citations": [ { "chunk_id": "how-refunds-work#0", "doc_id": "how-refunds-work", "score": 0.0164 } ],\n# "usage": { "retrieval_ms": 21, "retrieved": 1, "used": 1, \u2026 }, "finish": "stop" } }\n\nvxil api POST /v1/ai/judge --data \'{\n "input": "My card was charged twice. Can I get one back?",\n "candidate": "Refunds go back to the original payment method within 14 days of purchase.",\n "criteria": [ { "name": "answers the question asked", "weight": 2 },\n { "name": "is supported by the cited text", "weight": 2 } ],\n "scale": { "min": 0, "max": 10 } }\'\n# 200 { "data": { "generation_id": "gen_\u2026", "score": 10, "verdict": "pass", "rationale": "\u2026", \u2026 } }\n```\n\n`stream: false` is load-bearing. With streaming on (the default), this route answers with a\n`generation_id`, a channel, a token and a `resume_path` for a browser to attach to \u2014 the citations\narrive immediately and the text streams. A server-side step wants the finished text, so it asks for\nit. Getting this wrong is a silent empty draft, not an error.\n\n`citations` are the chunks that actually **survived the context budget** \u2014 not everything retrieved.\nThat distinction is what makes them auditable: every sentence in the draft is traceable to text in\nthe list. And the score is a forced integer on a fixed scale, so drafts are comparable to each\nother rather than each getting its own adjective.\n\nNothing was sent to a customer. The action writes `draft` and `draft_score` onto the ticket and\nstops \u2014 the last step is a person.\n\n**4. The assistant: propose, then confirm.** The copilot answers from the same index and, when a\nturn would *write*, stops and asks:\n\n```bash\nvxil api POST /v1/copilot/desk/messages --data \'{"user_id":"u_agent","message":"What is our refund window?"}\'\n# 200 { "data": { "conversation_id": "cnv_\u2026", "message_id": "msg_\u2026",\n# "answer": "Refunds are available within 14 days of purchase\u2026",\n# "action_status": "none", "citations": [ \u2026 ], \u2026 } }\n\nvxil api POST /v1/copilot/desk/messages --data \'{"conversation_id":"cnv_\u2026","user_id":"u_agent","message":"Open a ticket for Ana about the double charge."}\'\n# 200 { "data": { "message_id": "msg_\u2026", "action_status": "proposed",\n# "proposal": { "message_id": "msg_\u2026", "tool": "cms_create_item",\n# "args": { "collection": "tickets", "data": { "subject": "\u2026", \u2026 } },\n# "feature": "cms", "proposed_at": "\u2026", "require_confirm": true,\n# "confirm_path": "/v1/copilot/conversations/cnv_\u2026/actions/msg_\u2026/confirm" },\n# \u2026 } }\n```\n\nOn the **mock** provider that second turn answers `action_status: "none"` \u2014 the mock does not decide\nto call a tool on its own. Steer it with the marker the platform\'s own end-to-end tests use, and the\nturn produces a real proposal you can confirm:\n\n```text\nOpen a ticket for Ana about the double charge.\n[[tool_call:cms_create_item {"collection":"tickets","data":{"subject":"Double charge for Ana","requester":"u_ana","state":"open"}}]]\n```\n\nNothing has been written yet. The proposal names the tool and the exact arguments, and hands you\nthe confirm path. Commit it:\n\n```bash\nvxil api POST /v1/copilot/conversations/cnv_\u2026/actions/msg_\u2026/confirm\n# 200 { "data": { "message_id": "msg_\u2026", "proposal_message_id": "msg_\u2026", "action_status": "confirmed",\n# "confirmed_at": "\u2026", "result": { "data": { "item_id": "itm_\u2026", \u2026 } } } }\n```\n\nConfirm takes **no body** \u2014 the ids in the path are the whole request, which is what makes the\nlatch tamper-proof: you cannot confirm a *different* write than the one you were shown. Call it\ntwice and the second answers `already: true`. Wait fifteen minutes and it is\n`410 proposal_expired`. And the permission check runs **again at confirm time**, so a scope revoked\nbetween proposal and confirm stops the write.\n\nThe keys under `copilot.agents.desk.actions.allow` are tool names from the catalog \u2014\n`cms_query_items` (a read, run inline) and `cms_create_item` / `cms_run_item_action` (writes,\nproposed). An unknown key there is inert, never invented.\n\n**5. The same backend, as tools.** Point an agent at it:\n\n```bash\nvxil mcp install --client claude --scopes features:read,cms:read,ai:write,rag:read,vector-search:read,webhooks:read\n```\n\nThat mints a dedicated, `agent`-tagged, revocable key and writes the MCP server entry for your\nclient. Three layers decide what the agent can do, and they compose:\n\n1. **`mcp.exposureLevel: \'custom\'` + `allowToolList`** in this config \u2014 the tenant-wide surface.\n2. **the key\'s scopes** \u2014 what the underlying REST route will accept.\n3. **the key\'s `allowed_tools` / `denied_tools`** \u2014 a per-key narrowing on top, editable after\n minting without rotating the key.\n\n`features:read` is load-bearing: without it the policy probe (`GET /v1/config/mcp`) is refused and\nthe agent sees **zero** tools with no obvious error. Mint least privilege, but not less than that.\n\n**6. What can I react to here?** The last function answers the question an agent always has to ask\na human today:\n\n```bash\nvxil functions invoke agent-capabilities\n# { "catalog_events": 170,\n# "enabled_features": [ "ai", "cms", "copilot", "functions", "mcp", "rag", "vector-search", "webhooks" ],\n# "reactable_prefixes": [ { "prefix": "cms.item.", "count": \u2026 }, { "prefix": "ai.", "count": \u2026 }, \u2026 ],\n# "failure_events": [ "job.dead_lettered", "jobs.schedule.missed",\n# "webhooks.delivery.dead_lettered", \u2026 ],\n# "how_to_subscribe": "POST /v1/webhooks/subscriptions \u2026" }\n```\n\nIt reads `GET /v1/webhooks/events/catalog` \u2014 the machine-readable list of every lifecycle and\nfailure event the platform writes, with a prefix roll-up \u2014 and folds it against the features this\nbackend actually has on. The agent can call the catalog itself, too: `webhooks_event_catalog` is in\nthe tool list above, which is the difference between an agent that *has* tools and one that can\n**discover** what the system will tell it.\n\nActing on that discovery is one call with a key that carries `webhooks:write` \u2014 deliberately not\nthe read-only key this function holds:\n\n```bash\nvxil api POST /v1/webhooks/subscriptions --data \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["cms.item.","ai."]}\'\n```\n\n## What to learn from this\n\n- **Forcing the shape is the feature.** Classify returns one of *your* labels; judge returns an\n integer in *your* range. Most "the model went off the rails" problems are a missing schema, not a\n missing instruction.\n- **Two passes beat one long prompt.** Writing and evaluating are different jobs, and separating\n them gives you a number you can threshold, chart and regress against.\n- **Grounding is a pipeline, not a prompt trick.** Retrieval, a context budget, and citations of\n the chunks that survived it \u2014 the answer is auditable because the pipeline kept the receipts.\n- **Propose \u2192 confirm is where agent safety actually lives.** Not in a system prompt asking the\n model to be careful: in a latch that persists the exact arguments, re-checks permission at commit\n time, expires, and executes at most once.\n- **Least privilege for an agent is three layers, not one.** The tenant\'s exposure list, the key\'s\n scopes, and the key\'s per-tool narrowing \u2014 each can be tightened without touching the others.\n- **An agent should be able to ask the backend what it can do.** A tool catalog and an event\n catalog are both machine-readable for the same reason: the alternative is a prompt that goes stale\n the next time you ship.\n\n**Pairs with:** `templates/ai-journal/` (enrichment on write, and asking your own data questions)\nand `templates/helpdesk/` (the same desk without the AI half).\n',
8032
8794
  "functions": {
8033
8795
  "agent-capabilities.ts": "// agent-capabilities.ts \u2014 \"WHAT CAN I REACT TO HERE?\" (a vxil function).\n//\n// Trigger: http. An agent (or your own onboarding screen) calls this once and\n// learns, from the backend itself, what this workspace can emit \u2014 instead of a\n// human pasting a list into a prompt that goes stale the next release.\n//\n// Two reads, folded together:\n// \u2022 `GET /v1/webhooks/events/catalog` \u2014 the machine-readable list of every\n// lifecycle and failure event the platform writes, with a `prefixes` roll-up\n// you can subscribe to directly.\n// \u2022 `GET /v1/features` \u2014 which features THIS backend actually has on.\n// The answer is the intersection: the prefixes worth subscribing to here.\n//\n// WHY A KEY AND NOT THE FUNCTION'S OWN CALLBACK: a function's scoped callback\n// covers the feature APIs (cms, ai, rag, \u2026). The event catalog and the feature\n// list are platform reads, so this uses the narrowest key that can reach them \u2014\n// one holding only `features:read` and `webhooks:read`, stored as a secret,\n// resolved per invocation, revocable in one click without a redeploy.\n//\n// To actually SUBSCRIBE, POST to /v1/webhooks/subscriptions with\n// { target_url, event_prefixes } using a key that carries `webhooks:write` \u2014\n// deliberately NOT this one (see the README).\n\n/** prefix segment \u2192 the feature key it belongs to, where the names differ. */\nconst PREFIX_FEATURE: Record<string, string> = {\n job: 'jobs', jobs: 'jobs', user: 'auth', auth: 'auth', session: 'auth',\n org: 'orgs', orgs: 'orgs', rate_limits: 'rate-limits', feeds: 'activity-feed',\n 'vector-search': 'vector-search', functions: 'functions',\n};\n\ninterface Env {\n vxil_base?: string;\n secrets?: Record<string, string>;\n payload?: { all?: boolean };\n}\ninterface CatalogEvent { name?: string; feature?: string; level?: string }\ninterface Prefix { prefix?: string; count?: 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 key = env.secrets?.vxil_read_key;\n if (!key) {\n return Response.json(\n { error: 'missing_secret', message: 'set the vxil_read_key secret first' },\n { status: 503 },\n );\n }\n const h = { authorization: `Bearer ${key}` };\n\n const [catRes, featRes] = await Promise.all([\n fetch(`${base}/v1/webhooks/events/catalog`, { headers: h }),\n fetch(`${base}/v1/features`, { headers: h }),\n ]);\n if (!catRes.ok) {\n return Response.json({ error: 'catalog_unavailable', status: catRes.status }, { status: 502 });\n }\n const cat = ((await catRes.json()) as {\n data?: { count?: number; events?: CatalogEvent[]; prefixes?: Prefix[] };\n }).data ?? {};\n const enabled = new Set(\n featRes.ok\n ? ((await featRes.json()) as { data?: { features?: string[] } }).data?.features ?? []\n : [],\n );\n\n const all = env.payload?.all === true;\n const prefixes = (cat.prefixes ?? []).filter((p) => {\n if (all || enabled.size === 0) return true;\n const head = String(p.prefix ?? '').replace(/\\.$/, '');\n return enabled.has(PREFIX_FEATURE[head] ?? head);\n });\n\n // The failure half is the half worth wiring first: it is what tells you the\n // backend is unhappy before a customer does.\n const failures = (cat.events ?? [])\n .filter((e) => e.level === 'failure')\n .map((e) => e.name)\n .filter((n): n is string => typeof n === 'string')\n .sort();\n\n return Response.json({\n catalog_events: cat.count ?? (cat.events ?? []).length,\n enabled_features: [...enabled].sort(),\n reactable_prefixes: prefixes,\n failure_events: failures,\n how_to_subscribe:\n 'POST /v1/webhooks/subscriptions { \"target_url\": \"https://\u2026\", \"event_prefixes\": [\"job.\", \"cms.item.\"] } '\n + 'with a key carrying webhooks:write',\n });\n },\n};\n",
8034
- "draft-reply.ts": "// draft-reply.ts \u2014 RETRIEVE \u2192 GROUND \u2192 SCORE (a vxil function).\n//\n// Trigger: the per-record action `draft_reply` on `tickets`. The action envelope\n// carries the WHOLE row, so this step needs no re-fetch:\n// { collection, item_id, action, actor, item: { item_id, status, version, data } }\n//\n// Three calls, three jobs, in order:\n// 1. `POST /v1/rag/answer` \u2014 retrieve from the `kb` index and answer ONLY from\n// what came back, returning the chunks it used as citations. A grounded\n// answer you can audit beats a confident one you cannot.\n// 2. `POST /v1/ai/judge` \u2014 score that draft against a rubric, as an integer on\n// a fixed scale. The model that writes is not the authority on whether the\n// writing is good; a second, schema-forced pass is.\n// 3. one PATCH \u2014 persist the draft + its score so a human decides what to send.\n//\n// Nothing here sends anything to a customer. The last step is always a person.\n\nconst CRITERIA = [\n { name: 'answers the question asked', weight: 2 },\n { name: 'is supported by the cited knowledge-base text', weight: 2 },\n { name: 'is concise and free of speculation', weight: 1 },\n];\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: {\n collection?: string;\n item_id?: string;\n item?: { data?: { subject?: string; body?: string; category?: string } };\n };\n}\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 rag = env.scoped_jwts?.rag;\n const ai = env.scoped_jwts?.ai;\n const itemId = env.payload?.item_id;\n if (!cms || !rag || !ai || !itemId || env.payload?.collection !== 'tickets') {\n return Response.json({ error: 'bad_request', message: 'not a tickets action' }, { status: 400 });\n }\n\n const t = env.payload?.item?.data ?? {};\n const question = `${t.subject ?? ''}\\n\\n${t.body ?? ''}`.trim();\n if (!question) return Response.json({ error: 'empty_ticket' }, { status: 422 });\n\n // 1. GROUNDED ANSWER. `template` falls back to the rag config's\n // `defaultTemplate`, so the call stays this short. `stream: false` is\n // load-bearing: with streaming enabled (the default) this route answers\n // with a channel + resume path for a browser to attach to, NOT the text.\n // A server-side step wants the text, so it says so.\n const answered = await fetch(`${base}/v1/rag/answer`, {\n method: 'POST',\n headers: { authorization: `Bearer ${rag}`, 'content-type': 'application/json' },\n body: JSON.stringify({ query: question, collection: 'kb', top_k: 5, stream: false }),\n });\n if (!answered.ok) {\n const detail = await answered.text();\n return Response.json(\n { error: 'retrieval_failed', status: answered.status, detail: detail.slice(0, 300) },\n { status: 502 },\n );\n }\n const grounded = ((await answered.json()) as {\n data?: { answer?: string; citations?: unknown[]; usage?: unknown };\n }).data ?? {};\n const draft = String(grounded.answer ?? '').trim();\n const citations = Array.isArray(grounded.citations) ? grounded.citations : [];\n if (!draft) return Response.json({ error: 'empty_draft' }, { status: 502 });\n\n // 2. SCORE IT. A forced integer on a fixed scale \u2014 comparable across drafts,\n // unlike \"this looks good\".\n const scored = await fetch(`${base}/v1/ai/judge`, {\n method: 'POST',\n headers: { authorization: `Bearer ${ai}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n input: question,\n candidate: draft,\n criteria: CRITERIA,\n scale: { min: 0, max: 10 },\n }),\n });\n const verdict = scored.ok\n ? ((await scored.json()) as { data?: { score?: number; verdict?: string; rationale?: string } }).data ?? {}\n : {};\n const score = typeof verdict.score === 'number' ? Math.round(verdict.score) : null;\n\n // 3. PERSIST. A human reads it, edits it, and decides whether it is sent.\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({\n data: { draft, ...(score === null ? {} : { draft_score: score }), state: 'drafted' },\n }),\n });\n\n return Response.json({\n item_id: itemId,\n draft,\n score,\n verdict: verdict.verdict ?? null,\n rationale: verdict.rationale ?? null,\n citations,\n written: patch.ok,\n });\n },\n};\n",
8035
- "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\nconst LABELS = ['billing', 'bug', 'how_to', 'other'];\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\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 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"
8796
+ "draft-reply.ts": "// draft-reply.ts \u2014 RETRIEVE \u2192 GROUND \u2192 SCORE (a vxil function).\n//\n// Trigger: the per-record action `draft_reply` on `tickets`. The action envelope\n// carries the WHOLE row, so this step needs no re-fetch:\n// { collection, item_id, action, actor, item: { item_id, status, version, data } }\n//\n// Three calls, three jobs, in order:\n// 1. `POST /v1/rag/answer` \u2014 retrieve from the `kb` index and answer ONLY from\n// what came back, returning the chunks it used as citations. A grounded\n// answer you can audit beats a confident one you cannot.\n// 2. `POST /v1/ai/judge` \u2014 score that draft against a rubric, as an integer on\n// a fixed scale. The model that writes is not the authority on whether the\n// writing is good; a second, schema-forced pass is.\n// 3. one PATCH \u2014 persist the draft + its score so a human decides what to send.\n//\n// Nothing here sends anything to a customer. The last step is always a person.\n\nconst CRITERIA = [\n { name: 'answers the question asked', weight: 2 },\n { name: 'is supported by the cited knowledge-base text', weight: 2 },\n { name: 'is concise and free of speculation', weight: 1 },\n];\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: {\n collection?: string;\n item_id?: string;\n item?: { data?: { subject?: string; body?: string; category?: string } };\n };\n}\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 rag = env.scoped_jwts?.rag;\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 || !rag || !ai || !itemId || env.payload?.collection !== 'tickets') {\n return Response.json({ error: 'bad_request', message: 'not a tickets action' }, { status: 400 });\n }\n\n const t = env.payload?.item?.data ?? {};\n const question = `${t.subject ?? ''}\\n\\n${t.body ?? ''}`.trim();\n if (!question) return Response.json({ error: 'empty_ticket' }, { status: 422 });\n\n // 1. GROUNDED ANSWER. `template` falls back to the rag config's\n // `defaultTemplate`, so the call stays this short. `stream: false` is\n // load-bearing: with streaming enabled (the default) this route answers\n // with a channel + resume path for a browser to attach to, NOT the text.\n // A server-side step wants the text, so it says so.\n const answered = await fetch(`${base}/v1/rag/answer`, {\n method: 'POST',\n headers: { authorization: `Bearer ${rag}`, 'content-type': 'application/json' },\n body: JSON.stringify({ query: question, collection: 'kb', top_k: 5, stream: false }),\n });\n if (!answered.ok) {\n const detail = await answered.text();\n return Response.json(\n { error: 'retrieval_failed', status: answered.status, detail: detail.slice(0, 300) },\n { status: 502 },\n );\n }\n const grounded = ((await answered.json()) as {\n data?: { answer?: string; citations?: unknown[]; usage?: unknown };\n }).data ?? {};\n const draft = String(grounded.answer ?? '').trim();\n const citations = Array.isArray(grounded.citations) ? grounded.citations : [];\n if (!draft) return Response.json({ error: 'empty_draft' }, { status: 502 });\n\n // 2. SCORE IT. A forced integer on a fixed scale \u2014 comparable across drafts,\n // unlike \"this looks good\".\n const scored = await fetch(`${base}/v1/ai/judge`, {\n method: 'POST',\n headers: { authorization: `Bearer ${ai}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n input: question,\n candidate: draft,\n criteria: CRITERIA,\n scale: { min: 0, max: 10 },\n }),\n });\n const verdict = scored.ok\n ? ((await scored.json()) as { data?: { score?: number; verdict?: string; rationale?: string } }).data ?? {}\n : {};\n const score = typeof verdict.score === 'number' ? Math.round(verdict.score) : null;\n\n // 3. PERSIST. A human reads it, edits it, and decides whether it is sent.\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({\n data: { draft, ...(score === null ? {} : { draft_score: score }), state: 'drafted' },\n }),\n });\n\n return Response.json({\n item_id: itemId,\n draft,\n score,\n verdict: verdict.verdict ?? null,\n rationale: verdict.rationale ?? null,\n citations,\n written: patch.ok,\n });\n },\n};\n",
8797
+ "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\nconst LABELS = ['billing', 'bug', 'how_to', 'other'];\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\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"
8036
8798
  }
8037
8799
  },
8038
8800
  {
@@ -8303,7 +9065,7 @@ export default defineConfig({
8303
9065
  "functions": {
8304
9066
  "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\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string> }\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",
8305
9067
  "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\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n end_user?: { id: string };\n // the caller's HTTP body rides the invocation envelope under `payload` (functions.md \xA72)\n payload?: { cart_id?: string; success_url?: string; cancel_url?: string };\n}\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",
8306
- "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\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string>; payload?: { collection?: string; item_id?: string } }\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 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",
9068
+ "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\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string>; payload?: { collection?: string; item_id?: string } }\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",
8307
9069
  "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\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n // the caller's HTTP body rides the invocation envelope under `payload` (functions.md \xA72)\n payload?: { cart_id?: string; coupon_code?: string };\n}\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"
8308
9070
  }
8309
9071
  },
@@ -8584,7 +9346,7 @@ export default defineConfig({
8584
9346
  `,
8585
9347
  "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",
8586
9348
  "functions": {
8587
- "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\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n idempotency_key?: string;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\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 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",
9349
+ "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\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n idempotency_key?: string;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\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",
8588
9350
  "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\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string> }\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"
8589
9351
  }
8590
9352
  },
@@ -8631,7 +9393,7 @@ export default defineConfig({
8631
9393
  "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// \"Team Workspace\" \u2014 the ENTERPRISE blueprint: many companies inside one\n// backend, each with its own members and roles, signing in through the\n// company's own identity provider, reading a document set where ONE field is\n// visible only to finance. Declared end-to-end in ONE typed file.\n//\n// \u2022 orgs \u2192 organizations + memberships + a tenant-defined `finance` role\n// \u2022 auth \u2192 email/password or magic link today, a generic OIDC issuer as a\n// config swap; account-security controls; a 3-device session cap\n// \u2022 cms \u2192 projects \u2192 documents, owner-scoped, `restrict`-protected, with\n// ONE per-record action button and ONE role-gated field\n// \u2022 functions \u2192 the single step the Archive button runs\n//\n// The one thing that is NOT in this file: inviting a teammate to the vxil\n// PROJECT itself (the dashboard's pending-email invite). That is an operator\n// flow, not app config \u2014 see the README.\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 // \u2500\u2500 Workspaces for YOUR customers' teams \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // An organization is a customer company; a membership carries a role. The\n // built-in lattice is owner > admin > member > viewer; `finance` below is a\n // CUSTOM role you define once over the API (see the README) and then assign\n // like any built-in one.\n orgs: {\n enabled: true,\n maxMembersPerOrg: 200,\n invitationTtlHours: 72, // an org invitation token is single-use + TTL-bound\n },\n\n auth: {\n // The demo path: email+password (and magic link) so the walkthrough runs\n // with no identity provider at all.\n methods: { emailPassword: true, magicLink: true },\n\n // \u2500\u2500 SSO: the generic OIDC issuer, as a CONFIG SWAP \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Uncomment this block, store the client secret once, and every member of\n // the workspace signs in through the company's IdP instead. The endpoints\n // and signing keys are discovered from the issuer \u2014 nothing else changes\n // in this file, and no code changes at all. `clientId` is not a secret\n // (it rides every authorize URL); the secret stays a REFERENCE.\n //\n // providers: {\n // oidc: {\n // issuer: 'https://login.example-idp.com', // https, no query/fragment\n // clientId: 'vxil-team-workspace',\n // clientSecretRef: 'secret:oidc_client_secret', // the `secrets` block below\n // scopes: ['email', 'profile'], // `openid` is always added\n // claims: { email: 'email', name: 'name', roles: 'groups' },\n // allowedDomains: ['example.com'], // fail-closed domain fence\n // autoLink: true, // link to a matching verified email\n // },\n // },\n\n // Roles ride the SESSION. With this on, the member's active-org role is\n // embedded in the session at sign-in and refresh, so a read can be gated\n // on it without a round-trip. It is a SNAPSHOT (refreshed with the\n // session) \u2014 use the orgs permission check for revocation-grade calls.\n orgClaims: { enabled: true },\n\n // A member may hold at most three live sessions; a fourth sign-in takes\n // over the oldest (it is revoked, and the sign-in reports which).\n session: { ttlMinutes: 60, refreshTtlDays: 30, maxConcurrent: 3 },\n\n // \u2500\u2500 Account-security controls (all opt-in, all off by default) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n security: {\n // repeated bad passwords on one identifier \u2192 locked, with a retry hint\n lockout: { maxFailures: 5, windowMinutes: 15, lockMinutes: 15 },\n // refuse a sign-up / reset whose password appears in a breach corpus\n breachedPasswords: true,\n // EVERY caller-supplied return URL must match one of these exactly \u2014\n // the anti-open-redirect fence for magic links, resets and SSO returns.\n allowedRedirectOrigins: ['https://app.example.com'],\n // captchaSecretRef: 'turnstile_secret', // add to require a captcha token\n },\n },\n\n cms: {\n hooks: {\n // The DOCUMENT lifecycle, enforced atomically inside the same write.\n // `archived` is terminal; the Archive button below is just the last\n // legal transition, so the button and the API agree by construction.\n document_stage: {\n collection: 'documents',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.state == before.state'\n + \" || (before.state == 'draft' && (item.state == 'in_review' || item.state == 'archived'))\"\n + \" || (before.state == 'in_review' && (item.state == 'draft' || item.state == 'approved'))\"\n + \" || (before.state == 'approved' && item.state == 'archived')\",\n message: 'illegal document state transition',\n },\n },\n },\n\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 projects: {\n singular: 'project',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n // one project code per workspace \u2014 a duplicate is a clean 409\n code: { type: 'string', unique: true, indexSlot: 's2' },\n stage: { type: 'string', indexSlot: 's3' }, // discovery | active | closed\n created_at: { type: 'datetime', indexSlot: 't1' },\n summary: { type: 'text' },\n },\n },\n\n documents: {\n singular: 'document',\n // Owner-scoping: a signed-in member reads/edits only their OWN\n // documents. A no-op for server callers \u2014 your own backend still sees\n // the whole set.\n ownerField: 'author',\n\n // ONE human-initiated step per record. The dashboard renders a button\n // on every row; pressing it invokes the named function ONCE with\n // { collection, item_id, action, actor, item }. No conditions, no\n // chaining, no scheduling \u2014 the moment it needs branches it is a\n // function of your own, not a button.\n actions: [{ key: 'archive', label: 'Archive', fn: 'archive-document' }],\n\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n author: { type: 'string', indexSlot: 's2' }, // the owner (end-user id)\n // `restrict`: while a live document points at a project, deleting\n // that project is REFUSED (409) instead of silently orphaning or\n // cascading. The reverse read (`\u2026/backlinks`) tells you who holds it.\n project: { type: 'relation', relationTo: 'projects', onDelete: 'restrict', indexSlot: 's3' },\n state: { type: 'string', indexSlot: 's4' }, // draft | in_review | approved | archived\n // \u2500\u2500 FIELD-LEVEL READ SECURITY \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // Only a signed-in member whose session carries the `finance` role\n // ever receives this field. Everyone else gets the document WITHOUT\n // it \u2014 absent, not null \u2014 and cannot filter or sort on it either, so\n // it can never be read one bit at a time. Your own server key still\n // sees it: this gates END USERS, not you.\n budget_usd: { type: 'int', indexSlot: 'n1', readRoles: ['finance'] },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text' },\n },\n },\n },\n },\n\n // \u2500\u2500 The one step that isn't config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 functions: {\n // The Archive button. Invoked through the per-record action route with the\n // pressing member's verified principal carried whole, so the write it makes\n // is owner-scoped exactly as if the member had made it themselves.\n 'archive-document': {\n entry: './functions/archive-document.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // nothing external; it only talks back to your own backend\n },\n },\n\n // References only \u2014 values are stored once and never appear in this file.\n secrets: {\n oidc_client_secret: { feature: 'auth', description: 'OIDC client secret for the workspace identity provider' },\n },\n\n seed: {\n cms: [\n {\n collection: 'projects',\n items: [\n {\n name: 'Northwind Rollout',\n code: 'NW-2026',\n stage: 'active',\n created_at: '2026-01-06T09:00:00Z',\n summary: 'Migration of the Northwind account onto the new platform.',\n },\n ],\n },\n ],\n },\n});\n",
8632
9394
  "readme": '# Team Workspace (saas)\n\nThe **B2B** blueprint: many customer companies inside one backend, each with its own members\nand roles, signing in through the company\'s own identity provider \u2014 and a document set where\none field is visible only to finance.\n\nFive things most "add multi-tenancy to my SaaS" projects end up building by hand, declared here\ninstead: **organizations**, **roles that ride the session**, **SSO as a config swap**,\n**field-level read security**, and **one button per record**.\n\n```bash\nvxil init --template team-workspace\nvxil quickstart # or `vxil link <slug>` for an existing backend\nvxil push # collections + hooks + the archive function\n```\n\n## The five things, and where each one lives\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| Customer companies + memberships | `features.orgs` | `POST /v1/orgs`, members, invitations, a permission check \u2014 no membership table of your own |\n| A `finance` role | **not config** \u2014 `POST /v1/orgs/roles` | roles are rows, so you add one without a deploy |\n| Roles on the session | `features.auth.orgClaims.enabled` | the member\'s active-org role rides the session token; a read can be gated on it with no round-trip |\n| SSO | `features.auth.providers.oidc` (commented) | one block swaps email+password for the customer\'s identity provider |\n| Lockout / breach / redirect fence | `features.auth.security` | the account-security controls, all opt-in, all off until you ask |\n| A 3-device cap | `features.auth.session.maxConcurrent` | a fourth sign-in takes over the oldest session and tells you which |\n| Hiding `budget_usd` | `readRoles: [\'finance\']` on the field | the field is **absent** for everyone else \u2014 and unfilterable, so it cannot be read one bit at a time |\n| Refusing an orphaning delete | `onDelete: \'restrict\'` on the relation | deleting a project that still holds documents is a clean 409, not a cascade you did not ask for |\n| The Archive button | `actions: [{ key, label, fn }]` | one human-initiated step, one function, no workflow engine |\n\n## SSO \u2014 the config swap\n\nThe blueprint ships with email + password so the walkthrough runs with no identity provider.\nTo move a workspace onto its company\'s IdP, uncomment the `providers.oidc` block in\n`vxil.config.ts`, fill in three values, store one secret, and push:\n\n```ts\nproviders: {\n oidc: {\n issuer: \'https://login.example-idp.com\', // https, no query or fragment\n clientId: \'vxil-team-workspace\', // not a secret \u2014 it rides every authorize URL\n clientSecretRef: \'secret:oidc_client_secret\', // a REFERENCE; the value never enters this file\n scopes: [\'email\', \'profile\'], // `openid` is always added\n claims: { email: \'email\', name: \'name\', roles: \'groups\' },\n allowedDomains: [\'example.com\'], // fail-closed: an unlisted domain is refused\n autoLink: true, // link to an existing verified email\n },\n},\n```\n\n```bash\nprintf \'%s\' "$OIDC_SECRET" | vxil secrets set auth/oidc_client_secret\nvxil push\n```\n\nThen send people to `GET /v1/auth/oauth/oidc/start?redirect_uri=https://app.example.com/callback`.\nThe authorize endpoint, token endpoint and signing keys are **discovered from the issuer** \u2014 there\nis nothing else to configure and no code change at all. The presence of the block is the opt-in;\nthere is no separate toggle.\n\nTwo claims feed the session\'s role list: the member\'s **active-org role** (from `orgClaims`) and\nwhatever claim you name in `claims.roles` (from the IdP). Either one alone is enough to satisfy\n`readRoles: [\'finance\']`, which is why the same config works before and after SSO.\n\nAny broker that speaks OIDC \u2014 Okta, Entra, Auth0, WorkOS \u2014 puts a SAML customer behind this same\nblock. There is deliberately no separate SAML surface to learn.\n\n## Inviting people: two different invitations\n\nThey are easy to confuse, so name them apart:\n\n- **Your customers\' teammates** \u2192 `POST /v1/orgs/{org_id}/invitations` with `{ email, role }`,\n where `role` is `admin`, `member` or `viewer`. The single-use token comes back **once**, in that\n response \u2014 it is deliberately never emailed, so your app builds its own accept link and controls\n the wording. Accept with `POST /v1/orgs/invitations/accept`; list pending ones with\n `GET /v1/orgs/{org_id}/invitations`; revoke with `DELETE /v1/orgs/invitations/{invite_id}`.\n To land someone on a custom role such as `finance`, invite them as `member` and then\n `POST /v1/orgs/{org_id}/members` with the role. There is no resend \u2014 issue a new invitation and\n revoke the old one.\n- **Your own colleagues, on the vxil project itself** \u2192 the dashboard\'s **Members \u2192 Invite by\n email**. Type an address and it becomes a *pending* row with Resend and Revoke beside it; when\n they accept, they get a dashboard seat on this backend. That one is pure operator flow \u2014 no code,\n nothing in this config.\n\n## The 10-minute walkthrough\n\nEvery response below is the real shape. `$KEY` is a server key with `cms:read cms:write orgs:read\norgs:write auth:signin auth:write features:read`; `$API` is `https://api.vxil.com`.\n\n**1. Define the `finance` role.** Roles are rows, so this needs no deploy.\n\n```bash\nvxil api POST /v1/orgs/roles --data \'{"role_key":"finance","name":"Finance","permissions":["budgets.approve","reports.export"],"rank":2}\'\n# 201 { "data": { "role_key": "finance", "name": "Finance", "permissions": [...], "rank": 2, ... } }\n```\n\nA custom role is a named **permission set**, and the permission strings are yours \u2014 vxil never\ninterprets them, it only answers whether this member holds one. The four built-in roles\n(`owner > admin > member > viewer`) keep working alongside it.\n\n**2. Create a workspace and two members.**\n\n```bash\nvxil api POST /v1/orgs --data \'{"slug":"northwind","name":"Northwind","owner_user_id":"u_owner"}\'\n# 201 { "data": { "org_id": "org_\u2026", "slug": "northwind", "name": "Northwind", "created_at": "\u2026" } }\n```\n\nSign two people up, then seat them \u2014 one plain `member`, one on `finance`:\n\n```bash\nvxil api POST /v1/auth/sign-up --data \'{"email":"alice@example.com","password":"<a strong one>"}\'\nvxil api POST /v1/auth/sign-up --data \'{"email":"dana@example.com","password":"<a strong one>"}\'\nvxil api POST /v1/orgs/org_\u2026/members --data \'{"user_id":"<alice>","role":"member"}\'\nvxil api POST /v1/orgs/org_\u2026/members --data \'{"user_id":"<dana>","role":"finance"}\'\n# 201 { "data": { "org_id": "org_\u2026", "user_id": "\u2026", "role": "finance" } }\n```\n\nCheck what the session will carry:\n\n```bash\nvxil api GET "/v1/orgs/session-claims?user_id=<dana>"\n# 200 { "data": { "user_id": "\u2026", "org_id": "org_\u2026", "role": "finance", "perms": [...] } }\n```\n\n**3. Sign in \u2014 and watch the device cap.** Sign the same person in four times:\n\n```bash\ncurl -s -X POST "$API/v1/auth/sign-in" -H "authorization: Bearer $KEY" \\\n -H \'content-type: application/json\' \\\n -d \'{"email":"dana@example.com","password":"<a strong one>"}\'\n# 200 { "data": { "user_id": "\u2026",\n# "session": { "token": "\u2026", "refresh_token": "\u2026", "expires_at": "\u2026" },\n# "took_over": [ "sess_\u2026" ] } }\n```\n\nThe fourth sign-in reports the session it revoked in `took_over`. That array only appears because\n`session.maxConcurrent` is set \u2014 leave it out and responses are byte-identical to a backend that\nnever heard of the cap.\n\nRepeated wrong passwords stop being cheap after five: `429 account_locked` with a `Retry-After`\nheader, for fifteen minutes. Because the counter is keyed on a hash of the identifier, an unknown\naddress locks out exactly like a real one \u2014 no probing for which emails exist.\n\n**4. Write a document with a budget.** As the server key (no end-user session):\n\n```bash\nvxil api POST /v1/cms/items/projects --data \'{"data":{"name":"Northwind Rollout","code":"NW-2026","stage":"active"}}\'\nvxil api POST /v1/cms/items/documents --data \'{"data":{"title":"Statement of work","author":"<dana>","project":"<project item_id>","state":"draft","budget_usd":240000}}\'\n# 201 { "data": { "item_id": "itm_\u2026", "collection": "documents", "status": "draft",\n# "data": { "title": "\u2026", "budget_usd": 240000, \u2026 }, "version": 1, \u2026 } }\n```\n\nThe server key sees `budget_usd`. That is the point: the gate is for **end users**, not for you.\n\n**5. The gate, live.** Read the same document as a signed-in member, by sending the session\'s\n`token` in the `X-Vxil-End-User` header:\n\n```bash\n# dana \u2014 role `finance`\ncurl -s "$API/v1/cms/items/documents/<id>" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <dana\'s session token>"\n# 200 \u2026 "data": { "title": "Statement of work", "budget_usd": 240000, "state": "draft", \u2026 }\n\n# alice \u2014 role `member`\ncurl -s "$API/v1/cms/items/documents/<her own document\'s id>" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <alice\'s session token>"\n# 200 \u2026 "data": { "title": "\u2026", "state": "draft", \u2026 } \u2190 budget_usd is ABSENT\n```\n\nAbsent, not `null` \u2014 a `null` would itself be an answer. And it cannot be reached sideways either:\n\n```bash\ncurl -s "$API/v1/cms/items/documents?filter=%7B%22budget_usd%22%3A%7B%22%24gt%22%3A0%7D%7D" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <alice\'s session token>"\n# 422 { "error": { "code": "invalid_query", "message": "unknown field \'budget_usd\' \u2026" } }\n```\n\nTo a member without the role the field does not exist \u2014 not in the document, not in a filter, not\nin a sort, not through an expanded relation. Promote alice to `finance`, have her sign in again,\nand the field is simply there: the role travels on the session, so a new session is all it takes.\n\n**6. A delete that refuses.** The project still has a document pointing at it:\n\n```bash\nvxil api DELETE /v1/cms/items/projects/<project item_id>\n# 409 { "error": { "code": "referenced",\n# "message": "this item is still referenced by 1 live item(s) through an on_delete: \'restrict\' relation \u2026; nothing was deleted.",\n# "referencing": [ { "collection": "documents", "field": "project", "item_id": "itm_\u2026", "status": "draft" } ],\n# "has_more": false } }\n```\n\nAsk who is holding it, the same way the 409 did:\n\n```bash\nvxil api GET /v1/cms/items/projects/<project item_id>/backlinks\n# 200 { "data": { "collection": "projects", "item_id": "itm_\u2026",\n# "backlinks": [ { "collection": "documents", "field": "project", "item_id": "itm_\u2026", "status": "draft" } ],\n# "count": 1, "has_more": false, "limit": 25 } }\n```\n\n**7. The button.** One action, one function, one step:\n\n```bash\ncurl -s -X POST "$API/v1/cms/items/documents/<id>/actions/archive" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <dana\'s session token>"\n# 200 { "data": { "collection": "documents", "item_id": "itm_\u2026", "action": "archive",\n# "fn": "archive-document",\n# "result": { "archived": "itm_\u2026", "from": "draft", "state": "archived" } } }\n```\n\nThe function receives `{ collection, item_id, action, actor, item }` and runs with **the pressing\nmember\'s** verified identity, so its write is owner-scoped exactly as if they had made it. Press it\nagain and it answers `already: true` \u2014 a button a human can double-click needs to be idempotent.\n\nTry an illegal jump instead (`archived \u2192 draft`) and the collection\'s lifecycle hook rejects it\ninside the same write, so the button and the API can never disagree:\n\n```bash\nvxil api PATCH /v1/cms/items/documents/<id> --data \'{"data":{"state":"draft"}}\'\n# 422 \u2026 "illegal document state transition"\n```\n\n**8. Real authorization, when advisory is not enough.** The session role is a *snapshot*, refreshed\nwith the session. For anything that must reflect a revocation immediately, ask:\n\n```bash\nvxil api GET "/v1/orgs/org_\u2026/check?user_id=<dana>&permission=budgets.approve"\n# 200 { "data": { "org_id": "org_\u2026", "user_id": "\u2026", "permission": "budgets.approve",\n# "role": "finance", "allowed": true, "source": "role" } }\n```\n\n## What to learn from this\n\n- **Roles are data; the gate is config.** `finance` is a row you can create at 4pm on a Friday.\n `readRoles: [\'finance\']` is one field attribute. Neither is a code path you maintain.\n- **Field-level security has to be fail-safe in every direction, or it is theatre.** A gated field\n is removed from the document, from filters, from sorts, from expanded relations, and it is never\n served on a public read lane. The only caller that still sees it is your own backend.\n- **Owner-scoping and role-gating answer different questions.** `ownerField` decides *which rows*\n a member can see. `readRoles` decides *which fields* inside a row they get. You usually want both.\n- **`restrict` beats a cascade you did not think about.** Refusing the delete and naming the\n holders turns a data-loss bug into a 409 your UI can explain.\n- **An action is one step, on purpose.** The moment a button needs conditions or a second step, it\n is a function of yours, not a config entry \u2014 and that boundary is what keeps this from becoming a\n workflow engine.\n\n**Pairs with:** `templates/crm/` (the same relational spine without the org layer) and\n`templates/helpdesk/` (owner-scoped records with a state machine).\n',
8633
9395
  "functions": {
8634
- "archive-document.ts": "// archive-document.ts \u2014 the ARCHIVE BUTTON (a vxil function).\n//\n// Trigger: the per-record action `archive` declared on the `documents`\n// collection. Pressing the button POSTs\n// /v1/cms/items/documents/<id>/actions/archive\n// and the platform invokes THIS function once with the action envelope as its\n// payload:\n// { collection, item_id, action, actor, item: { item_id, status, version, data } }\n//\n// The pressing member's verified principal is carried into the scoped token, so\n// the PATCH below is owner-scoped exactly as if the member had written it \u2014 a\n// member can archive their own document and nobody else's, with no check here.\n//\n// It writes ONE transition (\u2192 'archived'). The collection's lifecycle hook is\n// still the authority: an illegal transition is rejected in the same write, and\n// this function reports that rejection instead of pretending it succeeded.\n\ninterface ActionPayload {\n collection?: string;\n item_id?: string;\n action?: string;\n actor?: { principal?: string; end_user_id?: string };\n item?: { status?: string; version?: number; data?: Record<string, unknown> };\n}\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: ActionPayload;\n}\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 itemId = env.payload?.item_id;\n if (!cms || !itemId || env.payload?.collection !== 'documents') {\n return Response.json({ skipped: true, reason: 'not a documents action' });\n }\n\n // Already archived \u2192 nothing to do. The action is human-initiated and can be\n // pressed twice; make the second press a no-op rather than an error.\n const was = String(env.payload?.item?.data?.state ?? '');\n if (was === 'archived') {\n return Response.json({ archived: itemId, already: true, state: 'archived' });\n }\n\n const res = await fetch(`${base}/v1/cms/items/documents/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: { state: 'archived', updated_at: new Date().toISOString() },\n }),\n });\n\n if (!res.ok) {\n // The lifecycle hook refuses an illegal transition in-transaction (422).\n // Surface the real reason \u2014 the action route relays this class straight\n // back to the caller as `action_failed`.\n const detail = await res.text();\n return Response.json(\n { error: 'archive_refused', from: was, status: res.status, detail: detail.slice(0, 300) },\n { status: res.status === 422 ? 422 : 502 },\n );\n }\n\n return Response.json({ archived: itemId, from: was || 'draft', state: 'archived' });\n },\n};\n"
9396
+ "archive-document.ts": "// archive-document.ts \u2014 the ARCHIVE BUTTON (a vxil function).\n//\n// Trigger: the per-record action `archive` declared on the `documents`\n// collection. Pressing the button POSTs\n// /v1/cms/items/documents/<id>/actions/archive\n// and the platform invokes THIS function once with the action envelope as its\n// payload:\n// { collection, item_id, action, actor, item: { item_id, status, version, data } }\n//\n// The pressing member's verified principal is carried into the scoped token, so\n// the PATCH below is owner-scoped exactly as if the member had written it \u2014 a\n// member can archive their own document and nobody else's, with no check here.\n//\n// It writes ONE transition (\u2192 'archived'). The collection's lifecycle hook is\n// still the authority: an illegal transition is rejected in the same write, and\n// this function reports that rejection instead of pretending it succeeded.\n\ninterface ActionPayload {\n collection?: string;\n item_id?: string;\n action?: string;\n actor?: { principal?: string; end_user_id?: string };\n item?: { status?: string; version?: number; data?: Record<string, unknown> };\n}\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: ActionPayload;\n}\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 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 || !itemId || env.payload?.collection !== 'documents') {\n return Response.json({ skipped: true, reason: 'not a documents action' });\n }\n\n // Already archived \u2192 nothing to do. The action is human-initiated and can be\n // pressed twice; make the second press a no-op rather than an error.\n const was = String(env.payload?.item?.data?.state ?? '');\n if (was === 'archived') {\n return Response.json({ archived: itemId, already: true, state: 'archived' });\n }\n\n const res = await fetch(`${base}/v1/cms/items/documents/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: { state: 'archived', updated_at: new Date().toISOString() },\n }),\n });\n\n if (!res.ok) {\n // The lifecycle hook refuses an illegal transition in-transaction (422).\n // Surface the real reason \u2014 the action route relays this class straight\n // back to the caller as `action_failed`.\n const detail = await res.text();\n return Response.json(\n { error: 'archive_refused', from: was, status: res.status, detail: detail.slice(0, 300) },\n { status: res.status === 422 ? 422 : 502 },\n );\n }\n\n return Response.json({ archived: itemId, from: was || 'draft', state: 'archived' });\n },\n};\n"
8635
9397
  }
8636
9398
  },
8637
9399
  {
@@ -9125,7 +9887,7 @@ export default defineConfig({
9125
9887
  ];
9126
9888
 
9127
9889
  // src/migrate/adapters/csv-json.ts
9128
- import { readFileSync as readFileSync6, readdirSync, existsSync as existsSync6, statSync } from "node:fs";
9890
+ import { readFileSync as readFileSync7, readdirSync, existsSync as existsSync7, statSync } from "node:fs";
9129
9891
  import { basename as basename3, extname, join as join3, resolve as resolve6 } from "node:path";
9130
9892
  function parseCsv(text) {
9131
9893
  const src = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
@@ -9244,11 +10006,11 @@ var CsvJsonAdapter = class {
9244
10006
  }
9245
10007
  load() {
9246
10008
  if (this.cache) return this.cache;
9247
- if (!existsSync6(this.dir)) throw new Error(`csv-json: directory not found: ${this.dir}`);
10009
+ if (!existsSync7(this.dir)) throw new Error(`csv-json: directory not found: ${this.dir}`);
9248
10010
  let overrides = {};
9249
10011
  const schemaFile = join3(this.dir, "_schema.json");
9250
- if (existsSync6(schemaFile)) {
9251
- overrides = JSON.parse(readFileSync6(schemaFile, "utf8"));
10012
+ if (existsSync7(schemaFile)) {
10013
+ overrides = JSON.parse(readFileSync7(schemaFile, "utf8"));
9252
10014
  }
9253
10015
  const warnings = [
9254
10016
  `csv-json: column types inferred from a \u2264${this.sampleSize}-row value sample \u2014 confirm the plan before load`
@@ -9301,7 +10063,7 @@ var CsvJsonAdapter = class {
9301
10063
  warnings
9302
10064
  };
9303
10065
  const objectsRoot = join3(this.dir, OBJECTS_DIR);
9304
- if (existsSync6(objectsRoot)) {
10066
+ if (existsSync7(objectsRoot)) {
9305
10067
  const buckets = readdirSync(objectsRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => ({ name: e.name, objectCount: walkFiles(join3(objectsRoot, e.name)).length })).sort((a, b2) => a.name.localeCompare(b2.name));
9306
10068
  if (buckets.length > 0) schema.buckets = buckets;
9307
10069
  }
@@ -9324,7 +10086,7 @@ var CsvJsonAdapter = class {
9324
10086
  return this.cache;
9325
10087
  }
9326
10088
  loadCsv(file, schemaName, tableName, overrides, warnings) {
9327
- const parsed = parseCsv(readFileSync6(file, "utf8"));
10089
+ const parsed = parseCsv(readFileSync7(file, "utf8"));
9328
10090
  const header = parsed[0];
9329
10091
  if (!header || header.length === 0) {
9330
10092
  warnings.push(`csv-json: ${basename3(file)} is empty (no header row) \u2014 skipped`);
@@ -9362,7 +10124,7 @@ var CsvJsonAdapter = class {
9362
10124
  return { schema: schemaName, name: tableName, columns, rows };
9363
10125
  }
9364
10126
  loadJson(file, schemaName, tableName, overrides, warnings) {
9365
- const parsed = JSON.parse(readFileSync6(file, "utf8"));
10127
+ const parsed = JSON.parse(readFileSync7(file, "utf8"));
9366
10128
  if (!Array.isArray(parsed)) {
9367
10129
  warnings.push(`csv-json: ${basename3(file)} is not a JSON array of row objects \u2014 skipped`);
9368
10130
  return void 0;
@@ -9436,7 +10198,7 @@ var CsvJsonAdapter = class {
9436
10198
  * read LAZILY per object via stream() — a dry-run never opens them. */
9437
10199
  async *readObjects(bucket) {
9438
10200
  const root = join3(this.dir, OBJECTS_DIR, bucket);
9439
- if (!existsSync6(root)) {
10201
+ if (!existsSync7(root)) {
9440
10202
  throw new Error(`csv-json: unknown bucket '${bucket}' \u2014 no ${OBJECTS_DIR}/${bucket}/ directory in the export`);
9441
10203
  }
9442
10204
  for (const rel of walkFiles(root)) {
@@ -9446,7 +10208,7 @@ var CsvJsonAdapter = class {
9446
10208
  path: rel,
9447
10209
  contentType: EXT_CONTENT_TYPES[ext] ?? "application/octet-stream",
9448
10210
  size: statSync(abs).size,
9449
- stream: async () => readFileSync6(abs)
10211
+ stream: async () => readFileSync7(abs)
9450
10212
  };
9451
10213
  }
9452
10214
  }
@@ -9629,7 +10391,7 @@ var catalogSql = {
9629
10391
  ORDER BY cl.relname, c.conname`,
9630
10392
  params: [schemas]
9631
10393
  }),
9632
- /** RLS policies verbatim (R15 residual; R6 owner-column grep source). */
10394
+ /** Source row-level access rules verbatim (R15 residual; R6 owner-column grep source). */
9633
10395
  rlsPolicies: (schemas) => ({
9634
10396
  text: `
9635
10397
  SELECT schemaname AS table_schema, tablename AS table_name,
@@ -10212,6 +10974,11 @@ function hasFlag(name) {
10212
10974
  return rest.includes(`--${name}`);
10213
10975
  }
10214
10976
  var jsonOut = rest.includes("--json");
10977
+ function ttlFlag(fallback) {
10978
+ const parsed = parseTtlFlag(flag("ttl"), fallback);
10979
+ if ("error" in parsed) fail(parsed.error);
10980
+ return parsed.hours;
10981
+ }
10215
10982
  function fail(msg, code = 1) {
10216
10983
  console.error(`vxil: ${msg}`);
10217
10984
  process.exit(code);
@@ -10303,6 +11070,8 @@ function persistLink(slug, tenantId, apiKey, base) {
10303
11070
  saveCredentials(creds);
10304
11071
  const as = flag("as");
10305
11072
  const env = flag("env") ?? envLabelFor(base);
11073
+ const envWarn = envLabelWarning(env, base);
11074
+ if (envWarn) console.error(`vxil: ${envWarn}`);
10306
11075
  const existing = loadProject();
10307
11076
  if (as !== void 0) {
10308
11077
  if (!TARGET_NAME_RE.test(as)) fail(`--as '${as}': a target name is 1\u201340 chars of [a-z0-9-], starting with a letter`);
@@ -10416,7 +11185,7 @@ function againstSelector(name) {
10416
11185
  }
10417
11186
  function loadDriftIgnore() {
10418
11187
  const f = resolve7(process.cwd(), ".vxil", "drift-ignore");
10419
- return parseIgnorePatterns(flag("ignore"), existsSync7(f) ? readFileSync7(f, "utf8") : void 0);
11188
+ return parseIgnorePatterns(flag("ignore"), existsSync8(f) ? readFileSync8(f, "utf8") : void 0);
10420
11189
  }
10421
11190
  async function runApply(apply, sel = "prod", opts = {}) {
10422
11191
  const gate = !apply && !!opts.gate;
@@ -10424,6 +11193,7 @@ async function runApply(apply, sel = "prod", opts = {}) {
10424
11193
  const { api, target } = requireApi(sel, apply ? { write: "push" } : gate ? { exitCode: 2 } : {});
10425
11194
  if (!apply) console.error(targetBanner(target, gate ? "diff" : "plan"));
10426
11195
  const report = { drift: 0, ignored: 0, errors: 0 };
11196
+ const compared = newComparedCounts({ apiState: !!opts.apiState });
10427
11197
  const ignore = apply ? [] : loadDriftIgnore();
10428
11198
  const ignoredKeys = [];
10429
11199
  const count = (key) => {
@@ -10441,18 +11211,55 @@ async function runApply(apply, sel = "prod", opts = {}) {
10441
11211
  process.exit(2);
10442
11212
  }
10443
11213
  const cwd = process.cwd();
10444
- if (cfg.cms?.collections && Object.keys(cfg.cms.collections).length) {
10445
- const r = await planCmsSchema({ api, collections: cfg.cms.collections, apply, allowDestructive: hasFlag("allow-destructive") });
11214
+ const notCompared = (u, hint) => {
11215
+ console.log(formatNotCompared(u, hint));
11216
+ if (gate) report.errors++;
11217
+ };
11218
+ const runSection = async (label, fn) => {
11219
+ if (!gate) {
11220
+ await fn();
11221
+ return;
11222
+ }
11223
+ try {
11224
+ await fn();
11225
+ } catch (e) {
11226
+ console.log(` ! not compared: ${label} \u2192 ${e.message.split("\n")[0]}`);
11227
+ report.errors++;
11228
+ }
11229
+ };
11230
+ await runSection("cms.schema", async () => {
11231
+ if (!cfg.cms?.collections || !Object.keys(cfg.cms.collections).length) return;
11232
+ const r = await planCmsSchema({
11233
+ api,
11234
+ collections: cfg.cms.collections,
11235
+ apply,
11236
+ allowDestructive: hasFlag("allow-destructive"),
11237
+ // the C-1 re-index loop can take several pages on a big collection —
11238
+ // print progress so a long push is never a silent stall
11239
+ onProgress: (line) => console.log(line)
11240
+ });
10446
11241
  console.log("\ncms.schema:");
11242
+ if (r.remoteUnavailable) {
11243
+ notCompared(r.remoteUnavailable, "needs cms:read, features:read or admin");
11244
+ return;
11245
+ }
10447
11246
  console.log(formatCmsChanges(r.changes));
10448
11247
  if (apply && r.applied) console.log(` \u2713 applied ${r.applied} schema change(s)`);
10449
11248
  for (const c of r.changes) count(`cms:${c.collection}${c.field ? `.${c.field}` : ""}`);
10450
- }
11249
+ compared.collections += Object.keys(cfg.cms.collections).length;
11250
+ });
10451
11251
  const { functions: _fnFeature, ...configFeatures } = cfg.features;
10452
11252
  void _fnFeature;
10453
- if (Object.keys(configFeatures).length) {
11253
+ await runSection("config", async () => {
11254
+ if (!Object.keys(configFeatures).length) return;
10454
11255
  const results = await planOrPush({ api, features: configFeatures, apply });
10455
11256
  for (const r of results) {
11257
+ if (r.remoteUnavailable) {
11258
+ console.log(`
11259
+ ${r.feature}:`);
11260
+ notCompared(r.remoteUnavailable, "needs features:read or admin");
11261
+ continue;
11262
+ }
10456
11263
  console.log(`
10457
11264
  ${r.feature} (remote v${r.version}):`);
10458
11265
  const { kept, ignored } = partitionConfigChanges(r.feature, r.changes, ignore);
@@ -10464,29 +11271,45 @@ ${r.feature} (remote v${r.version}):`);
10464
11271
  console.log(" provenance (declared in vxil.config vs schema default):");
10465
11272
  console.log(formatExplain(r.feature, explainLeaves(configFeatures[r.feature], r.effective), new Set(r.changes.map((c) => c.path))));
10466
11273
  }
11274
+ compared.features++;
11275
+ }
11276
+ });
11277
+ await runSection("functions", async () => {
11278
+ if (!cfg.functions || !Object.keys(cfg.functions).length) {
11279
+ if (cfg.features.functions) {
11280
+ 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).");
11281
+ }
11282
+ return;
10467
11283
  }
10468
- }
10469
- if (cfg.functions && Object.keys(cfg.functions).length) {
10470
11284
  const r = await planFunctions({ api, functions: cfg.functions, cwd, apply });
10471
11285
  console.log("\nfunctions:");
11286
+ if (r.remoteUnavailable) {
11287
+ notCompared(r.remoteUnavailable, "needs functions:read, features:read or admin");
11288
+ return;
11289
+ }
10472
11290
  console.log(formatFnChanges(r.changes));
10473
11291
  if (apply && r.applied) console.log(` \u2713 deployed ${r.applied} function(s)`);
10474
11292
  if (apply && r.cmsHookSubscriptions && (r.cmsHookSubscriptions.created || r.cmsHookSubscriptions.deleted)) {
10475
11293
  console.log(` \u2713 cms-hook subscription reconciled (+${r.cmsHookSubscriptions.created} / -${r.cmsHookSubscriptions.deleted})`);
10476
11294
  }
11295
+ if (apply && r.webhookSubscriptions && (r.webhookSubscriptions.created || r.webhookSubscriptions.deleted)) {
11296
+ console.log(` \u2713 webhook-trigger subscription reconciled (+${r.webhookSubscriptions.created} / -${r.webhookSubscriptions.deleted})`);
11297
+ }
10477
11298
  for (const c of r.changes) if (c.kind !== "unchanged") count(`function:${c.name}`);
10478
- } else if (cfg.features.functions) {
10479
- 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).");
10480
- }
11299
+ compared.functions += Object.keys(cfg.functions).length;
11300
+ });
10481
11301
  if (!apply && (gate || explain)) {
10482
- const refs = collectSecretRefs(cfg);
10483
- const res = await api("GET", "/v1/secrets");
10484
- console.log("\nsecrets (names only \u2014 values are never read or compared):");
10485
- if (res.status !== 200) {
10486
- const e = res.body.error ?? {};
10487
- console.log(` ! not compared: GET /v1/secrets \u2192 ${e.code ?? res.status}${e.message ? ` \u2014 ${e.message}` : ""} (needs features:write, secrets:write or admin)`);
10488
- if (gate) report.errors++;
10489
- } else {
11302
+ await runSection("secrets", async () => {
11303
+ const refs = collectSecretRefs(cfg);
11304
+ const res = await api("GET", "/v1/secrets");
11305
+ console.log("\nsecrets (names only \u2014 values are never read or compared):");
11306
+ if (res.status !== 200) {
11307
+ notCompared(
11308
+ { route: "GET /v1/secrets", status: res.status, ...res.body.error?.code ? { code: res.body.error.code } : {}, ...res.body.error?.message ? { message: res.body.error.message } : {} },
11309
+ "needs features:write, secrets:write or admin"
11310
+ );
11311
+ return;
11312
+ }
10490
11313
  const stored = res.body.data?.secrets ?? [];
10491
11314
  const cmp = compareSecretNames(refs, stored);
10492
11315
  if (!cmp.missing.length && !cmp.unreferenced.length) console.log(` (in sync \u2014 ${cmp.present.length} ref(s), all stored)`);
@@ -10497,15 +11320,61 @@ ${r.feature} (remote v${r.version}):`);
10497
11320
  }
10498
11321
  for (const u of cmp.unreferenced) console.log(` \xB7 ${u.feature}/${u.secret_name} stored but not referenced by this config (informational)`);
10499
11322
  if (cmp.missing.length) console.log(" note: a push with an unstored ref is a WARNING, never a 422 \u2014 set values after the push with `vxil secrets set <feature>/<name>`");
10500
- }
11323
+ compared.secrets += refs.length;
11324
+ });
11325
+ }
11326
+ const apiStateCounts = {
11327
+ policies: { compared: 0, drift: 0 },
11328
+ subscriptions: { compared: 0, drift: 0 },
11329
+ campaigns: { compared: 0, drift: 0 }
11330
+ };
11331
+ if (opts.apiState) {
11332
+ await runSection("api-state", async () => {
11333
+ const self = requireApi(opts.apiState.self, { exitCode: 2 });
11334
+ const other = requireApi(opts.apiState.against, { exitCode: 2 });
11335
+ console.log(`
11336
+ api state \u2014 ${self.target.tenantSlug ?? "(env key)"} vs ${other.target.tenantSlug ?? "(env key)"} (read-only: \`vxil push\` does NOT converge API state, and a config rollback does not restore it):`);
11337
+ const [a, b2] = await Promise.all([fetchApiState(self.api), fetchApiState(other.api)]);
11338
+ for (const u of a.unavailable) notCompared(u, `target ${self.target.tenantSlug ?? ""}`.trim());
11339
+ for (const u of b2.unavailable) notCompared(u, `--against ${other.target.tenantSlug ?? ""}`.trim());
11340
+ for (const w of [...truncationWarnings(a, "the target"), ...truncationWarnings(b2, "--against")]) {
11341
+ console.log(w);
11342
+ if (gate) report.errors++;
11343
+ }
11344
+ if (a.unavailable.length || b2.unavailable.length) return;
11345
+ const rows = diffApiState(a, b2);
11346
+ console.log(formatApiStateRows(rows));
11347
+ for (const r of rows) count(apiStateDriftKey(r));
11348
+ const union = (x, y, k) => (/* @__PURE__ */ new Set([...x.map(k), ...y.map(k)])).size;
11349
+ apiStateCounts.policies.compared = union(a.policies, b2.policies, (p) => p.name);
11350
+ apiStateCounts.subscriptions.compared = union(a.subscriptions, b2.subscriptions, (x) => x.target_url);
11351
+ apiStateCounts.campaigns.compared = union(a.campaigns, b2.campaigns, (c) => c.name);
11352
+ for (const r of rows) {
11353
+ if (r.section === "policy") apiStateCounts.policies.drift++;
11354
+ else if (r.section === "subscription") apiStateCounts.subscriptions.drift++;
11355
+ else apiStateCounts.campaigns.drift++;
11356
+ }
11357
+ compared.policies = apiStateCounts.policies.compared;
11358
+ compared.subscriptions = apiStateCounts.subscriptions.compared;
11359
+ compared.campaigns = apiStateCounts.campaigns.compared;
11360
+ });
10501
11361
  }
10502
11362
  if (gate) {
10503
11363
  const code = driftExitCode(report);
10504
11364
  if (jsonOut) {
10505
- console.log(JSON.stringify({ ok: code === 0, exit_code: code, target: target.tenantSlug ?? null, env: target.envLabel, ...report, ignored_keys: ignoredKeys }));
11365
+ console.log(JSON.stringify({
11366
+ ok: code === 0,
11367
+ exit_code: code,
11368
+ target: target.tenantSlug ?? null,
11369
+ env: target.envLabel,
11370
+ ...report,
11371
+ compared,
11372
+ ignored_keys: ignoredKeys,
11373
+ ...opts.apiState ? { api_state: apiStateCounts } : {}
11374
+ }));
10506
11375
  } else {
10507
11376
  console.log(`
10508
- vxil diff \u2192 ${report.drift} drift item(s), ${report.ignored} ignored, ${report.errors} error(s) \u2014 exit ${code}`);
11377
+ vxil diff \u2192 ${report.drift} drift item(s), ${report.ignored} ignored, ${report.errors} error(s) \u2014 compared ${formatCompared(compared)} \u2014 exit ${code}`);
10509
11378
  }
10510
11379
  process.exit(code);
10511
11380
  }
@@ -10603,7 +11472,12 @@ async function runGen(sel = "prod", opts = {}) {
10603
11472
  required: !!f.required,
10604
11473
  index_slot: f.indexSlot ?? null,
10605
11474
  ...f.relationTo ? { relation_to: f.relationTo } : {},
10606
- ...f.computed ? { computed: f.computed } : {}
11475
+ ...f.computed ? { computed: f.computed } : {},
11476
+ // `validation.enum` becomes a string-literal union in the generated
11477
+ // types, so the OFFLINE collector must carry it too (the online path
11478
+ // gets it from GET /v1/cms/collections, which returns the whole row) —
11479
+ // else `vxil gen --offline` and `vxil gen` would disagree.
11480
+ ...f.validation ? { validation: f.validation } : {}
10607
11481
  }))
10608
11482
  }));
10609
11483
  functions = Object.entries(cfg.functions ?? {}).map(([name, def]) => ({
@@ -10651,15 +11525,15 @@ async function runGen(sel = "prod", opts = {}) {
10651
11525
  ...catalog ? { tools: catalog.tools.length } : {}
10652
11526
  };
10653
11527
  if (hasFlag("check")) {
10654
- const existing = existsSync7(outPath) ? readFileSync7(outPath, "utf8") : "";
11528
+ const existing = existsSync8(outPath) ? readFileSync8(outPath, "utf8") : "";
10655
11529
  if (body(existing) !== body(src)) {
10656
11530
  fail(`${out} is out of date (drift) \u2014 run \`vxil gen\` and commit. A collection/feature/function changed since it was generated.`);
10657
11531
  }
10658
11532
  if (catalog) {
10659
11533
  let upToDate = false;
10660
- if (existsSync7(mcpPath)) {
11534
+ if (existsSync8(mcpPath)) {
10661
11535
  try {
10662
- const cur = JSON.parse(readFileSync7(mcpPath, "utf8"));
11536
+ const cur = JSON.parse(readFileSync8(mcpPath, "utf8"));
10663
11537
  const want = JSON.parse(JSON.stringify(catalog));
10664
11538
  delete cur.generated_at;
10665
11539
  delete want.generated_at;
@@ -10684,6 +11558,7 @@ async function runGen(sel = "prod", opts = {}) {
10684
11558
  }
10685
11559
  async function runWatch(sel) {
10686
11560
  const cwd = process.cwd();
11561
+ await promotionGate(resolveTargetOrFail(sel), false);
10687
11562
  console.log("vxil dev --watch: applying on save (Ctrl-C to stop)\u2026");
10688
11563
  const apply = async () => {
10689
11564
  try {
@@ -10704,11 +11579,11 @@ async function runWatch(sel) {
10704
11579
  }, 300);
10705
11580
  };
10706
11581
  const watchers = [];
10707
- const cfgFile = CONFIG_FILENAMES.map((n) => resolve7(cwd, n)).find((f) => existsSync7(f));
11582
+ const cfgFile = CONFIG_FILENAMES.map((n) => resolve7(cwd, n)).find((f) => existsSync8(f));
10708
11583
  if (cfgFile) watchers.push(watch(cfgFile, trigger));
10709
11584
  for (const dir of ["functions", "cms"]) {
10710
11585
  const d = resolve7(cwd, dir);
10711
- if (existsSync7(d)) watchers.push(watch(d, trigger));
11586
+ if (existsSync8(d)) watchers.push(watch(d, trigger));
10712
11587
  }
10713
11588
  process.on("SIGINT", () => {
10714
11589
  for (const w of watchers) w.close();
@@ -10718,22 +11593,19 @@ async function runWatch(sel) {
10718
11593
  await new Promise(() => {
10719
11594
  });
10720
11595
  }
10721
- function mockFeatures(cfg) {
10722
- return Object.entries(cfg.features).filter(([, conf]) => {
10723
- const c = conf;
10724
- return c.provider === "mock" || c.defaultProvider === "mock" || c.embed?.provider === "mock";
10725
- }).map(([f]) => f);
10726
- }
10727
- function isProductionTarget(t) {
10728
- return t.envLabel === "production" && t.slot !== "dev" && !(t.slot === "named" && t.selector.kind === "named" && t.selector.name === "dev");
10729
- }
10730
11596
  async function promotionGate(t, explicitProd) {
10731
11597
  const resolvedProd = isProductionTarget(t);
10732
11598
  if (!resolvedProd && !explicitProd) return;
11599
+ const labelNotice = unrecognizedLabelNotice(t);
11600
+ if (labelNotice) console.error(`vxil: ${labelNotice}`);
10733
11601
  const cfg = await loadVxilConfig();
10734
- const mocks = mockFeatures(cfg);
10735
- if (mocks.length && !hasFlag("allow-mock-in-prod")) {
10736
- fail(`refusing to push to PRODUCTION tenant '${t.tenantSlug ?? "(env key)"}' (${t.envLabel} \xB7 resolved via ${describeSelector(t.selector)}): mock providers on [${mocks.join(", ")}] would ship to production (mock email/payments silently no-op). Set a real provider, or override with --allow-mock-in-prod.`);
11602
+ const where = `'${t.tenantSlug ?? "(env key)"}' (${t.envLabel} \xB7 resolved via ${describeSelector(t.selector)})`;
11603
+ const notice = redirectOriginNotice(cfg);
11604
+ if (notice) console.error(`vxil: production review \u2014 ${notice}`);
11605
+ const refusals = promotionRefusals(cfg, hasFlag);
11606
+ if (refusals.length) {
11607
+ fail(`refusing to push to PRODUCTION tenant ${where}:
11608
+ ` + refusals.map((r) => ` - ${r.message}`).join("\n"));
10737
11609
  }
10738
11610
  if (hasFlag("yes")) return;
10739
11611
  if (!process.stdin.isTTY && !explicitProd) {
@@ -10771,6 +11643,8 @@ async function runDoctor() {
10771
11643
  if (proj) {
10772
11644
  const slots = bindingSlots(proj).map((s) => `${s.label}=${s.slug}`);
10773
11645
  checks.push({ name: "bound slots", ok: true, detail: `${slots.join(" \xB7 ")}${proj.dev ? "" : " (no dev slot: `--dev` fails closed until `vxil dev up` / `vxil link <slug> --as dev`)"}` });
11646
+ const expiry = devSlotExpiryCheck(proj.dev, /* @__PURE__ */ new Date());
11647
+ if (expiry) checks.push(expiry);
10774
11648
  }
10775
11649
  if (t?.apiKey) {
10776
11650
  const api = makeApi({ apiKey: t.apiKey, baseUrl: t.baseUrl });
@@ -10791,6 +11665,12 @@ async function runDoctor() {
10791
11665
  ));
10792
11666
  }
10793
11667
  }
11668
+ if (live.has("payments")) {
11669
+ const pres = await api("GET", "/v1/config/payments");
11670
+ if (pres.status === 200) {
11671
+ checks.push(...paymentsWebhookSecretChecks(pres.body.data?.manifest));
11672
+ }
11673
+ }
10794
11674
  } else {
10795
11675
  checks.push({ name: "edge reachable", ok: false, detail: `GET /v1/features \u2192 ${fres.status}` });
10796
11676
  }
@@ -10836,7 +11716,7 @@ async function runDoctor() {
10836
11716
  }
10837
11717
  if (cfg?.functions) {
10838
11718
  for (const [n, def] of Object.entries(cfg.functions)) {
10839
- const ok = existsSync7(resolve7(process.cwd(), def.entry));
11719
+ const ok = existsSync8(resolve7(process.cwd(), def.entry));
10840
11720
  checks.push({ name: `function '${n}' entry`, ok, detail: ok ? def.entry : `missing file: ${def.entry}` });
10841
11721
  }
10842
11722
  }
@@ -10894,13 +11774,13 @@ function scaffoldProject(configSrc, fns) {
10894
11774
  const entries = Object.entries(fns ?? {});
10895
11775
  if (entries.length > 0) {
10896
11776
  for (const [file, src] of entries) writeFileSync6(resolve7(cwd, "functions", file), src);
10897
- } else if (!existsSync7(resolve7(cwd, "functions/hello.ts"))) {
11777
+ } else if (!existsSync8(resolve7(cwd, "functions/hello.ts"))) {
10898
11778
  writeFileSync6(resolve7(cwd, "functions/hello.ts"), STARTER_FN);
10899
11779
  }
10900
11780
  mkdirSync5(resolve7(cwd, ".vxil"), { recursive: true });
10901
11781
  const gi = resolve7(cwd, ".gitignore");
10902
11782
  const ignore = ["", "# vxil", ".vxil/", ".env.local", ""].join("\n");
10903
- if (!existsSync7(gi) || !readFileSync7(gi, "utf8").includes(".vxil/")) appendFileSync2(gi, ignore);
11783
+ if (!existsSync8(gi) || !readFileSync8(gi, "utf8").includes(".vxil/")) appendFileSync2(gi, ignore);
10904
11784
  const pkg = ensureScaffoldPackageJson(cwd);
10905
11785
  if (pkg.action === "skipped") console.log(` ! package.json not patched: ${pkg.reason}`);
10906
11786
  return pkg;
@@ -10908,7 +11788,7 @@ function scaffoldProject(configSrc, fns) {
10908
11788
  function initProject() {
10909
11789
  const cwd = process.cwd();
10910
11790
  const cfgPath = resolve7(cwd, "vxil.config.ts");
10911
- if (existsSync7(cfgPath) && !hasFlag("force")) fail("vxil.config.ts already exists (use --force to overwrite)");
11791
+ if (existsSync8(cfgPath) && !hasFlag("force")) fail("vxil.config.ts already exists (use --force to overwrite)");
10912
11792
  const template = flag("template") ?? "tasks";
10913
11793
  const tpl = TEMPLATE_CATALOG.find((t) => t.id === template);
10914
11794
  if (!tpl) fail(`unknown template '${template}' \u2014 run \`vxil templates\` to list the gallery, or choose: ${TEMPLATE_CATALOG.map((t) => t.id).join(", ")}`);
@@ -10950,7 +11830,7 @@ try {
10950
11830
  const base = process.env.VXIL_BASE_URL ?? DEFAULT_BASE;
10951
11831
  const dash = dashboardBase(base);
10952
11832
  const featuresFlag = flag("features");
10953
- const hasConfig = CONFIG_FILENAMES.some((n) => existsSync7(resolve7(process.cwd(), n)));
11833
+ const hasConfig = CONFIG_FILENAMES.some((n) => existsSync8(resolve7(process.cwd(), n)));
10954
11834
  const features = quickstartFeatures(featuresFlag, featuresFlag === void 0 && hasConfig ? await loadVxilConfig() : null);
10955
11835
  const email = flag("email") ?? await prompt("email: ");
10956
11836
  const password = flag("password") ?? await prompt("password (>=10 chars): ", { hidden: true });
@@ -10962,7 +11842,7 @@ try {
10962
11842
  features,
10963
11843
  // --dev mints an ephemeral preview tenant the server-side TTL reaper
10964
11844
  // tears down after --ttl hours (default 72) — the per-PR CI backend.
10965
- ...hasFlag("dev") ? { kind: "dev", ttl_hours: Number(flag("ttl") ?? 72) } : {},
11845
+ ...hasFlag("dev") ? { kind: "dev", ttl_hours: ttlFlag(72) } : {},
10966
11846
  // --invite: required for ACCOUNT CREATION while the backend runs the
10967
11847
  // invite-only launch gate (existing accounts sign in ungated).
10968
11848
  ...flag("invite") ? { invite_code: flag("invite") } : {}
@@ -10986,7 +11866,7 @@ try {
10986
11866
  const slot = { tenant_id: d.tenant_id, slug: d.app_slug, edge_url: d.edge_url ?? base, env: flag("env") ?? envLabelFor(base) };
10987
11867
  const merged = mergePrimaryBinding(loadProject(), slot);
10988
11868
  saveProject(hasFlag("dev") ? { ...merged, dev: { tenant_id: slot.tenant_id, slug: slot.slug, edge_url: slot.edge_url } } : merged);
10989
- if (!existsSync7(resolve7(process.cwd(), "vxil.config.ts"))) initProject();
11869
+ if (!existsSync8(resolve7(process.cwd(), "vxil.config.ts"))) initProject();
10990
11870
  console.log(`
10991
11871
  \u2713 tenant '${d.app_slug}' created \xB7 enabled: ${(d.enabled ?? []).join(", ")}`);
10992
11872
  if (d.expires_at) console.log(` dev tenant \u2014 expires ${d.expires_at} (the nightly reaper tears it down; extend by recreating)`);
@@ -11054,7 +11934,7 @@ try {
11054
11934
  ephemeral: true,
11055
11935
  ...result.expires_at ? { expires_at: result.expires_at } : {}
11056
11936
  });
11057
- if (!existsSync7(resolve7(process.cwd(), "vxil.config.ts"))) scaffoldProject(TRY_CONFIG);
11937
+ if (!existsSync8(resolve7(process.cwd(), "vxil.config.ts"))) scaffoldProject(TRY_CONFIG);
11058
11938
  let genOk = true;
11059
11939
  try {
11060
11940
  await runGen("prod", { offline: true });
@@ -11175,7 +12055,14 @@ ${formatSummary(state)}`);
11175
12055
  case "diff": {
11176
12056
  const against = flag("against");
11177
12057
  const sel = against !== void 0 ? againstSelector(against) : selector();
11178
- await runApply(false, sel, { explain: hasFlag("explain"), gate: against !== void 0 || hasFlag("exit-code") });
12058
+ if (hasFlag("api-state") && against === void 0) {
12059
+ fail("--api-state has no local source of truth \u2014 compare two live targets: `vxil diff --api-state --against <name>`", 2);
12060
+ }
12061
+ await runApply(false, sel, {
12062
+ explain: hasFlag("explain"),
12063
+ gate: against !== void 0 || hasFlag("exit-code"),
12064
+ ...hasFlag("api-state") ? { apiState: { self: selector(), against: againstSelector(against) } } : {}
12065
+ });
11179
12066
  break;
11180
12067
  }
11181
12068
  case "push": {
@@ -11277,7 +12164,7 @@ ${plan.rationale}`);
11277
12164
  export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { collections: cmsBlock } }, null, 2)});
11278
12165
  `;
11279
12166
  const out = flag("out") ?? "vxil.config.ts";
11280
- if (existsSync7(resolve7(process.cwd(), out)) && !hasFlag("force")) fail(`${out} exists (use --force or --out <file>)`);
12167
+ if (existsSync8(resolve7(process.cwd(), out)) && !hasFlag("force")) fail(`${out} exists (use --force or --out <file>)`);
11281
12168
  writeFileSync6(resolve7(process.cwd(), out), src);
11282
12169
  const pkgRes = ensureScaffoldPackageJson(process.cwd());
11283
12170
  if (pkgRes.action === "created" || pkgRes.action === "added") {
@@ -11387,7 +12274,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
11387
12274
  const file = flag("file");
11388
12275
  if (!feature || !file) fail("usage: vxil import <feature> --file <bundle.json> [--mode merge|replace]");
11389
12276
  const { api } = requireApi(selector(), { write: `import ${feature}` });
11390
- const bundle = JSON.parse(readFileSync7(resolve7(process.cwd(), file), "utf8"));
12277
+ const bundle = JSON.parse(readFileSync8(resolve7(process.cwd(), file), "utf8"));
11391
12278
  printEnvelope(await api("POST", `/v1/import/${feature}`, { bundle, mode: flag("mode") ?? "merge" }));
11392
12279
  break;
11393
12280
  }
@@ -11401,7 +12288,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
11401
12288
  if (!name) fail("usage: vxil functions new <name>");
11402
12289
  mkdirSync5(resolve7(process.cwd(), "functions"), { recursive: true });
11403
12290
  const p = resolve7(process.cwd(), `functions/${name}.ts`);
11404
- if (existsSync7(p) && !hasFlag("force")) fail(`functions/${name}.ts exists`);
12291
+ if (existsSync8(p) && !hasFlag("force")) fail(`functions/${name}.ts exists`);
11405
12292
  writeFileSync6(p, STARTER_FN);
11406
12293
  console.log(`scaffolded functions/${name}.ts \u2014 declare it in vxil.config.ts \`functions\` and \`vxil push\``);
11407
12294
  } else if (sub === "list") {
@@ -11481,8 +12368,22 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
11481
12368
  printEnvelope(await api("GET", "/v1/cms/collections"));
11482
12369
  } else if (sub === "pull") {
11483
12370
  printEnvelope(await api("GET", "/v1/cms/collections"));
12371
+ } else if (sub === "reindex") {
12372
+ const [collection] = positional(1);
12373
+ if (!collection) fail("usage: vxil cms reindex <collection> [--field <field>]");
12374
+ const field = flag("field");
12375
+ const done = await reindexCollection({
12376
+ api,
12377
+ collection,
12378
+ ...field ? { field } : {},
12379
+ onProgress: (p) => console.log(` \u2026 scanned ${p.scanned}, re-projected ${p.updated}` + (p.skipped > 0 ? `, ${p.skipped} skipped (written concurrently)` : ""))
12380
+ }).catch((e) => fail(e.message));
12381
+ console.log(`\u2713 re-indexed ${collection}${field ? `.${field}` : ""}: ${done.scanned} row(s) scanned, ${done.updated} re-projected` + (done.skipped > 0 ? `, ${done.skipped} skipped` : ""));
12382
+ if (done.skipped > 0) {
12383
+ console.log(` \u26A0 ${done.skipped} row(s) were written while the re-index ran and were NOT re-projected \u2014 re-run \`vxil cms reindex ${collection}\` when writes are quiet`);
12384
+ }
11484
12385
  } else {
11485
- fail("usage: vxil cms status|pull");
12386
+ fail("usage: vxil cms status|pull|reindex <collection> [--field <field>]");
11486
12387
  }
11487
12388
  break;
11488
12389
  }
@@ -11578,7 +12479,8 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
11578
12479
  init [--template <id>] \xB7 templates \xB7 try (keyless sandbox) \xB7 quickstart [--dev --ttl <h>] \xB7 login \xB7 link <slug> [--as dev|<name>]
11579
12480
  architect "<describe your app>" \u2014 plain English in, a reviewed vxil.config.ts draft out
11580
12481
  plan [--explain] \xB7 push [--dev|--target <name>|--prod] [--server [--resume <apply_id>]] [--no-gen] \xB7 pull \xB7 gen [--check] [--offline] [--out <f>] [--no-mcp] [--mcp-out <f>]
11581
- diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] \u2014 CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error (secrets by name only)
12482
+ push production overrides: --allow-mock-in-prod \xB7 --allow-sandbox-in-prod \xB7 --allow-dev-origins-in-prod \xB7 --yes
12483
+ diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--api-state] \u2014 CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error (an unreadable remote is 2; secrets by name only)
11582
12484
  doctor [--dev|--target <name>] \u2014 + secrets referenced-vs-stored (advisory) + per-function served/published/local hash
11583
12485
  versions <feature> \xB7 rollback <feature> --to <v>
11584
12486
  secrets set|list [--all-projects]|rm \xB7 seed \xB7 export|import <feature>
@@ -11587,7 +12489,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
11587
12489
  migrate payments --from-provider stripe|paddle|paypal|revenuecat (--customers <f.csv> | --all) [--dry-run] \u2014 backfill subscriptions via the server sync leg (resumable)
11588
12490
  payments simulate --scenario refund-pair|cross-platform-unlock|renewal|expiry|past-due-grace|transfer --user <id> [--tier <t>] \u2014 mock/dev projects only
11589
12491
  env pull [--dev] [--file <f>] [--print] [--no-gitignore]
11590
- functions new|deploy|list|delete|invoke [--async]|logs [--tail]|dev <name> [--port <n>] \xB7 cms status|pull \xB7 dev up [--ttl <h>]|down [--yes]|seed|reset
12492
+ functions new|deploy|list|delete|invoke [--async]|logs [--tail]|dev <name> [--port <n>] \xB7 cms status|pull|reindex <collection> \xB7 dev up [--ttl <h>] [--new]|down [--yes]|seed|reset
11591
12493
  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
11592
12494
  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
11593
12495
  dev branch [<name>] [--ttl <h=24>] [--no-env] [--print] | --list | --rm <name> [--yes]
@@ -11621,13 +12523,7 @@ async function runDev(sub) {
11621
12523
  const dash = dashboardBase(base);
11622
12524
  const proj = loadProject();
11623
12525
  if (sub === "up") {
11624
- const name = flag("name") ?? `${proj?.slug ?? "app"}-dev`;
11625
- const d = await devUpCreate(dash, name, Number(flag("ttl") ?? 72));
11626
- const binding = proj ?? { tenant_id: d.tenant_id, slug: d.slug, edge_url: d.edge_url ?? base };
11627
- binding.dev = { tenant_id: d.tenant_id, slug: d.slug, edge_url: d.edge_url ?? base };
11628
- saveProject(binding);
11629
- console.log(`\u2713 dev tenant '${d.slug}' ready (keyless mock providers). Use \`vxil push --dev\` / \`vxil gen\` against it.`);
11630
- if (d.expires_at) console.log(` expires ${d.expires_at} \u2014 the nightly reaper deletes it then (re-run \`vxil dev up\` to extend).`);
12526
+ await runDevUp(dash, base, proj);
11631
12527
  } else if (sub === "branch") {
11632
12528
  await runDevBranch(dash, base);
11633
12529
  } else if (sub === "seed") {
@@ -11646,6 +12542,61 @@ async function runDev(sub) {
11646
12542
  fail("usage: vxil dev up|down|seed|reset|branch");
11647
12543
  }
11648
12544
  }
12545
+ async function runDevUp(dash, base, proj) {
12546
+ const ttlHours = ttlFlag(72);
12547
+ const creds = loadCredentials();
12548
+ const decision = hasFlag("new") ? { create: true } : pickDevSlot(proj, /* @__PURE__ */ new Date(), (slug) => Boolean(creds.keys?.[slug]));
12549
+ if ("reuse" in decision) {
12550
+ const slot = decision.reuse;
12551
+ let expiresAt = slot.expires_at;
12552
+ let reaped = false;
12553
+ try {
12554
+ const dashFn = makeDash(dash);
12555
+ if (!haveDashSession(creds, flag("email") ?? process.env.VXIL_DEV_EMAIL, flag("password") ?? process.env.VXIL_DEV_PASSWORD)) {
12556
+ throw new Error("no dashboard session \u2014 run `vxil login` (or pass --email/--password) to extend the TTL");
12557
+ }
12558
+ const cookie = await resolveDashCookie(dashFn);
12559
+ const r = await devExtend(dashFn, cookie, { tenant_id: slot.tenant_id }, ttlHours);
12560
+ if ("gone" in r) {
12561
+ console.log(` ! dev tenant '${slot.slug}' is gone (already reaped) \u2014 creating a fresh one`);
12562
+ reaped = true;
12563
+ } else if ("notDev" in r) {
12564
+ console.log(`\u2713 reusing '${slot.slug}' as the dev slot \u2014 it is a STANDARD project (bound with vxil link --as dev), so it has no TTL to extend`);
12565
+ } else {
12566
+ expiresAt = r.expires_at ?? expiresAt;
12567
+ console.log(`\u2713 reusing dev tenant '${slot.slug}'${expiresAt ? ` \u2014 TTL extended to ${expiresAt}` : " \u2014 TTL extended"}`);
12568
+ }
12569
+ } catch (e) {
12570
+ console.log(` ! could not extend the dev TTL (${e.message.split("\n")[0]}) \u2014 reusing '${slot.slug}' as-is`);
12571
+ }
12572
+ if (!reaped) {
12573
+ const kept = loadProject() ?? { tenant_id: slot.tenant_id, slug: slot.slug, edge_url: slot.edge_url };
12574
+ kept.dev = {
12575
+ tenant_id: slot.tenant_id,
12576
+ slug: slot.slug,
12577
+ edge_url: slot.edge_url,
12578
+ ...expiresAt ? { expires_at: expiresAt } : {}
12579
+ };
12580
+ saveProject(kept);
12581
+ console.log(" use `vxil push --dev` / `vxil gen` against it; --new forces a fresh tenant.");
12582
+ return;
12583
+ }
12584
+ }
12585
+ const name = flag("name") ?? `${proj?.slug ?? "app"}-dev`;
12586
+ const d = await devUpCreate(dash, name, ttlHours);
12587
+ const binding = loadProject() ?? { tenant_id: d.tenant_id, slug: d.slug, edge_url: d.edge_url ?? base };
12588
+ binding.dev = {
12589
+ tenant_id: d.tenant_id,
12590
+ slug: d.slug,
12591
+ edge_url: d.edge_url ?? base,
12592
+ ...d.expires_at ? { expires_at: d.expires_at } : {}
12593
+ };
12594
+ saveProject(binding);
12595
+ console.log(`\u2713 dev tenant '${d.slug}' ready (keyless mock providers). Use \`vxil push --dev\` / \`vxil gen\` against it.`);
12596
+ if (d.expires_at) {
12597
+ console.log(` expires ${d.expires_at} \u2014 the nightly reaper deletes it then; re-run \`vxil dev up\` to extend the TTL (the same tenant is reused; --new forces a fresh one).`);
12598
+ }
12599
+ }
11649
12600
  async function runDevBranch(dash, base) {
11650
12601
  const proj = loadProject();
11651
12602
  if (hasFlag("list")) {
@@ -11683,7 +12634,7 @@ async function runDevBranch(dash, base) {
11683
12634
  slot = decision.reuse;
11684
12635
  console.log(`\u2713 reusing dev branch '${name}' (tenant '${slot.slug}'${slot.expires_at ? ` \xB7 expires ${slot.expires_at}` : ""})`);
11685
12636
  } else {
11686
- const d = await devUpCreate(dash, `${proj?.slug ?? "app"}-${name}`, Number(flag("ttl") ?? 24));
12637
+ const d = await devUpCreate(dash, `${proj?.slug ?? "app"}-${name}`, ttlFlag(24));
11687
12638
  slot = {
11688
12639
  tenant_id: d.tenant_id,
11689
12640
  slug: d.slug,
@@ -11771,7 +12722,7 @@ function runEnvPull(sel) {
11771
12722
  const fileArg = flag("file") ?? ".env.local";
11772
12723
  const file = resolve7(process.cwd(), fileArg);
11773
12724
  const note = guardEnvFile(fileArg);
11774
- const existing = existsSync7(file) ? readFileSync7(file, "utf8") : "";
12725
+ const existing = existsSync8(file) ? readFileSync8(file, "utf8") : "";
11775
12726
  writeFileSync6(file, mergeEnvFile(existing, updates));
11776
12727
  try {
11777
12728
  chmodSync2(file, 384);
@@ -11853,7 +12804,7 @@ function guardEnvFile(fileArg) {
11853
12804
  const inRepo = git(["rev-parse", "--is-inside-work-tree"]);
11854
12805
  if (inRepo === "no-git") {
11855
12806
  const gi = resolve7(cwd, ".gitignore");
11856
- const text = existsSync7(gi) ? readFileSync7(gi, "utf8") : "";
12807
+ const text = existsSync8(gi) ? readFileSync8(gi, "utf8") : "";
11857
12808
  return appendOrWarn(gitignoreCovers(text, fileArg));
11858
12809
  }
11859
12810
  if (inRepo !== 0) return void 0;
@@ -11874,9 +12825,9 @@ async function runMcpInstall() {
11874
12825
  client = clientFlag;
11875
12826
  } else {
11876
12827
  const hits = [];
11877
- if (existsSync7(resolve7(cwd, ".cursor"))) hits.push("cursor");
11878
- if (existsSync7(resolve7(cwd, ".vscode"))) hits.push("vscode");
11879
- if (existsSync7(resolve7(cwd, ".claude")) || existsSync7(resolve7(cwd, ".mcp.json"))) hits.push("claude");
12828
+ if (existsSync8(resolve7(cwd, ".cursor"))) hits.push("cursor");
12829
+ if (existsSync8(resolve7(cwd, ".vscode"))) hits.push("vscode");
12830
+ if (existsSync8(resolve7(cwd, ".claude")) || existsSync8(resolve7(cwd, ".mcp.json"))) hits.push("claude");
11880
12831
  if (hits.length !== 1) {
11881
12832
  fail(hits.length === 0 ? "could not detect an editor in this directory \u2014 pass --client cursor|claude|vscode" : `multiple editor markers found (${hits.join(", ")}) \u2014 pass --client cursor|claude|vscode`);
11882
12833
  }
@@ -11889,7 +12840,7 @@ async function runMcpInstall() {
11889
12840
  }
11890
12841
  const t = resolve_target({ which: "prod", defaultBase: DEFAULT_BASE });
11891
12842
  const url = mcpUrlFor(t.baseUrl, process.env.VXIL_MCP_URL);
11892
- const target = configTarget(client, scope, { home: homedir2(), cwd, platform: process.platform });
12843
+ const target = configTarget(client, scope, { home: homedir3(), cwd, platform: process.platform });
11893
12844
  const explicitKey = flag("key");
11894
12845
  const inline = needsInlineKey(client, scope);
11895
12846
  if (hasFlag("print")) {
@@ -11977,7 +12928,7 @@ hint: ${e.hint}` : ""}`);
11977
12928
  return;
11978
12929
  }
11979
12930
  const ref = keyRef(client, scope, key);
11980
- const existing = existsSync7(target.path) ? readFileSync7(target.path, "utf8") : null;
12931
+ const existing = existsSync8(target.path) ? readFileSync8(target.path, "utf8") : null;
11981
12932
  let merged;
11982
12933
  try {
11983
12934
  merged = mergeMcpConfig(existing, { url, header: ref.header, ...ref.inputs ? { inputs: ref.inputs } : {} }, target.format, serverName);
@@ -11987,7 +12938,7 @@ hint: ${e.hint}` : ""}`);
11987
12938
  }
11988
12939
  let guardNote;
11989
12940
  if (client === "cursor" && scope === "project") guardNote = guardEnvFile(".cursor/mcp.json");
11990
- mkdirSync5(dirname4(target.path), { recursive: true });
12941
+ mkdirSync5(dirname5(target.path), { recursive: true });
11991
12942
  writeFileSync6(target.path, merged);
11992
12943
  if (inline) {
11993
12944
  try {
@@ -12106,7 +13057,7 @@ async function runFunctionsDev(name) {
12106
13057
  });
12107
13058
  });
12108
13059
  let timer2 = null;
12109
- const watcher = watch(dirname4(entry), () => {
13060
+ const watcher = watch(dirname5(entry), () => {
12110
13061
  if (timer2) clearTimeout(timer2);
12111
13062
  timer2 = setTimeout(() => {
12112
13063
  void (async () => {
@@ -12211,7 +13162,7 @@ ${migrateUsage()}`);
12211
13162
  const { api } = requireApi(selector(), dryRun ? {} : { write: `migrate payments --from-provider ${provider}` });
12212
13163
  const report = await runPaymentsSync({ api, cwd, log }, {
12213
13164
  provider,
12214
- ...customersFile ? { customers: parseCustomersCsv(readFileSync7(resolve7(cwd, customersFile), "utf8")) } : {},
13165
+ ...customersFile ? { customers: parseCustomersCsv(readFileSync8(resolve7(cwd, customersFile), "utf8")) } : {},
12215
13166
  ...all ? { all: true } : {},
12216
13167
  ...dryRun ? { dryRun: true } : {}
12217
13168
  });