@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-feature-configs.d.ts +19 -6
- package/dist/config.d.ts +44 -3
- package/dist/vxil.js +1174 -223
- package/package.json +4 -4
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
|
|
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
|
|
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
|
|
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
|
|
19
|
-
if (t === "cron") return [{ kind: "cron", ...
|
|
20
|
-
if (t === "queue") return [{ kind: "queue", ...
|
|
21
|
-
if (t === "webhook") return [{ kind: "webhook", ...
|
|
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
|
-
...
|
|
26
|
-
...
|
|
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", ...
|
|
45
|
+
return [{ kind: "authHook", ...str2("event") ? { event: str2("event") } : {} }];
|
|
31
46
|
}
|
|
32
|
-
return [{ kind: "http", ...
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
361
|
-
if (
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
|
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
|
|
476
|
-
|
|
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
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
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,
|
|
514
|
-
{ config: "validation", wire: "validation", remote: "validation", blank: {},
|
|
515
|
-
{ config: "indexSlot", wire: "index_slot", remote: "index_slot", blank: null,
|
|
516
|
-
{ config: "relationTo", wire: "relation_to", remote: "relation_to", blank: null,
|
|
517
|
-
{ config: "computed", wire: "computed", remote: "computed", blank: false,
|
|
518
|
-
{ config: "compute", wire: "compute", remote: "compute", blank: null,
|
|
519
|
-
{ config: "unique", wire: "unique", remote: "is_unique", blank: false,
|
|
520
|
-
{ config: "onDelete", wire: "on_delete", remote: "on_delete", blank: null,
|
|
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,
|
|
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
|
|
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
|
-
|
|
559
|
-
|
|
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 (
|
|
563
|
-
else
|
|
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
|
-
|
|
571
|
-
|
|
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
|
|
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
|
-
|
|
962
|
+
collChanges.push({ collection: name, kind: "add-field", field: fn, detail: `${name}.${fn} (${fd.type})` });
|
|
602
963
|
} else if (existing.type !== fd.type) {
|
|
603
|
-
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
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} ${
|
|
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
|
-
|
|
1009
|
+
collChanges.push({
|
|
632
1010
|
collection: name,
|
|
633
1011
|
kind: "warn",
|
|
634
1012
|
field: fn,
|
|
635
|
-
detail: `${name}.${fn} ${
|
|
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
|
-
|
|
1025
|
+
collChanges.push({
|
|
641
1026
|
collection: name,
|
|
642
1027
|
kind: "warn",
|
|
643
1028
|
field: fn,
|
|
644
|
-
detail: `${name}.${fn} drift: ${drift.join("; ")} \u2014
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1338
|
-
return '"' +
|
|
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
|
|
1828
|
+
let str2 = x[0];
|
|
1414
1829
|
for (let i = 1; i < x.length; i++)
|
|
1415
|
-
|
|
1416
|
-
return
|
|
1830
|
+
str2 += x[i] === "_" ? x[++i].toUpperCase() : x[i];
|
|
1831
|
+
return str2;
|
|
1417
1832
|
};
|
|
1418
1833
|
var toPascal = (x) => {
|
|
1419
|
-
let
|
|
1834
|
+
let str2 = x[0].toUpperCase();
|
|
1420
1835
|
for (let i = 1; i < x.length; i++)
|
|
1421
|
-
|
|
1422
|
-
return
|
|
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(
|
|
2334
|
-
bytes_default().P().str(name + bytes_default.N).str(
|
|
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 =
|
|
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 =
|
|
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)}?: ${
|
|
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),
|
|
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
|
|
5439
|
+
import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2, chmodSync } from "node:fs";
|
|
4940
5440
|
function credDir() {
|
|
4941
|
-
return join2(
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
|
5653
|
-
import { dirname as
|
|
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
|
|
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: `
|
|
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}
|
|
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
|
|
6139
|
-
import { dirname as
|
|
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 (!
|
|
7714
|
+
if (!existsSync4(stateFile)) return newMigrateState();
|
|
6953
7715
|
let parsed;
|
|
6954
7716
|
try {
|
|
6955
|
-
parsed = JSON.parse(
|
|
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(
|
|
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 =
|
|
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 /
|
|
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 (!
|
|
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(
|
|
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 (!
|
|
7984
|
+
if (!existsSync5(p.paymentsFile)) return void 0;
|
|
7223
7985
|
let parsed;
|
|
7224
7986
|
try {
|
|
7225
|
-
parsed = JSON.parse(
|
|
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 (!
|
|
8069
|
+
if (!existsSync6(path)) return { version: 1, provider, done: {} };
|
|
7308
8070
|
let parsed;
|
|
7309
8071
|
try {
|
|
7310
|
-
parsed = JSON.parse(
|
|
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(
|
|
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
|
|
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
|
|
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 (!
|
|
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 (
|
|
9251
|
-
overrides = JSON.parse(
|
|
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 (
|
|
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(
|
|
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(
|
|
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 (!
|
|
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 () =>
|
|
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
|
-
/**
|
|
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"),
|
|
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
|
-
|
|
10445
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10479
|
-
|
|
10480
|
-
}
|
|
11299
|
+
compared.functions += Object.keys(cfg.functions).length;
|
|
11300
|
+
});
|
|
10481
11301
|
if (!apply && (gate || explain)) {
|
|
10482
|
-
|
|
10483
|
-
|
|
10484
|
-
|
|
10485
|
-
|
|
10486
|
-
|
|
10487
|
-
|
|
10488
|
-
|
|
10489
|
-
|
|
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({
|
|
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 =
|
|
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 (
|
|
11534
|
+
if (existsSync8(mcpPath)) {
|
|
10661
11535
|
try {
|
|
10662
|
-
const cur = JSON.parse(
|
|
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) =>
|
|
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 (
|
|
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
|
|
10735
|
-
|
|
10736
|
-
|
|
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 =
|
|
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 (!
|
|
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 (!
|
|
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 (
|
|
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) =>
|
|
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:
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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}`,
|
|
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 =
|
|
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 =
|
|
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 (
|
|
11878
|
-
if (
|
|
11879
|
-
if (
|
|
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:
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
13165
|
+
...customersFile ? { customers: parseCustomersCsv(readFileSync8(resolve7(cwd, customersFile), "utf8")) } : {},
|
|
12215
13166
|
...all ? { all: true } : {},
|
|
12216
13167
|
...dryRun ? { dryRun: true } : {}
|
|
12217
13168
|
});
|