@vxil/cli 0.5.0 → 0.5.2
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/README.md +6 -2
- package/dist/_vxil-feature-configs.d.ts +25 -3
- package/dist/config.d.ts +44 -25
- package/dist/vxil.js +308 -68
- package/package.json +2 -2
package/dist/vxil.js
CHANGED
|
@@ -47,7 +47,7 @@ function lowerTriggerBindings(trigger) {
|
|
|
47
47
|
return [{ kind: "http", ...str2("path") ? { path: str2("path") } : {} }];
|
|
48
48
|
}
|
|
49
49
|
var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
|
|
50
|
-
var VXIL_CONFIG_PKG_VERSION = "0.4.
|
|
50
|
+
var VXIL_CONFIG_PKG_VERSION = "0.4.1";
|
|
51
51
|
function ensureScaffoldPackageJson(cwd) {
|
|
52
52
|
const file = resolve(cwd, "package.json");
|
|
53
53
|
const spec = `^${VXIL_CONFIG_PKG_VERSION}`;
|
|
@@ -393,6 +393,14 @@ var POLICY_LIST_CAP = 100;
|
|
|
393
393
|
var CAMPAIGN_LIST_CAP = 200;
|
|
394
394
|
var LEGACY_POLICY_ALGORITHM = "sliding_window";
|
|
395
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
|
+
}
|
|
396
404
|
function str(v) {
|
|
397
405
|
return typeof v === "string" ? v : void 0;
|
|
398
406
|
}
|
|
@@ -447,6 +455,7 @@ async function fetchApiState(api) {
|
|
|
447
455
|
const s = raw;
|
|
448
456
|
const url = str(s.target_url);
|
|
449
457
|
if (!url) continue;
|
|
458
|
+
if (isFnTriggerSubscription(url)) continue;
|
|
450
459
|
snap.subscriptions.push({
|
|
451
460
|
target_url: url,
|
|
452
461
|
event_prefixes: strList(s.event_prefixes),
|
|
@@ -542,7 +551,7 @@ import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync
|
|
|
542
551
|
import { homedir } from "node:os";
|
|
543
552
|
import { dirname as dirname2, resolve as resolve2, relative, isAbsolute, sep } from "node:path";
|
|
544
553
|
import { createHash } from "node:crypto";
|
|
545
|
-
var MAX_SOURCE_BYTES =
|
|
554
|
+
var MAX_SOURCE_BYTES = 512e3;
|
|
546
555
|
function sourceSha12(source) {
|
|
547
556
|
return createHash("sha256").update(source, "utf8").digest("hex").slice(0, 12);
|
|
548
557
|
}
|
|
@@ -600,7 +609,7 @@ function assertBundleContained(projectRoot, inputs, opts = {}) {
|
|
|
600
609
|
const root = resolve2(projectRoot);
|
|
601
610
|
const rootReal = realpathSafe(root);
|
|
602
611
|
const wsReal = realpathSafe(opts.workspaceRoot ?? workspaceRootCached(root));
|
|
603
|
-
const note = opts.onEscape ?? ((msg) => console.
|
|
612
|
+
const note = opts.onEscape ?? ((msg) => console.error(msg));
|
|
604
613
|
for (const input of inputs) {
|
|
605
614
|
const abs = resolve2(root, input);
|
|
606
615
|
const absReal = realpathSafe(abs);
|
|
@@ -632,9 +641,11 @@ async function bundleFunction(entryPath, opts = {}) {
|
|
|
632
641
|
const root = resolve2(opts.projectRoot ?? process.cwd());
|
|
633
642
|
const workspaceRoot = workspaceRootCached(root);
|
|
634
643
|
const entry = resolve2(entryPath);
|
|
635
|
-
|
|
644
|
+
const contained = { workspaceRoot, ...opts.onNote ? { onEscape: opts.onNote } : {} };
|
|
645
|
+
assertBundleContained(root, [entry], { ...contained, what: "entry" });
|
|
636
646
|
const esbuild = await import("esbuild");
|
|
637
|
-
const
|
|
647
|
+
const minify = opts.minify ?? true;
|
|
648
|
+
const build = (min) => esbuild.build({
|
|
638
649
|
entryPoints: [entry],
|
|
639
650
|
bundle: true,
|
|
640
651
|
format: "esm",
|
|
@@ -643,22 +654,45 @@ async function bundleFunction(entryPath, opts = {}) {
|
|
|
643
654
|
target: "es2022",
|
|
644
655
|
write: false,
|
|
645
656
|
legalComments: "none",
|
|
657
|
+
minify: min,
|
|
658
|
+
keepNames: min,
|
|
659
|
+
// readable stack traces / log lines on the minified bundle
|
|
660
|
+
sourcemap: false,
|
|
661
|
+
// never uploaded, never emitted
|
|
646
662
|
absWorkingDir: root,
|
|
647
663
|
metafile: true,
|
|
648
664
|
// drives the import-containment check below (non-executing)
|
|
649
665
|
plugins: []
|
|
650
666
|
// explicitly none — build plugins are code execution
|
|
651
667
|
});
|
|
668
|
+
const result = await build(minify);
|
|
652
669
|
const out = result.outputFiles?.[0];
|
|
653
670
|
if (!out) throw new Error(`esbuild produced no output for ${entryPath}`);
|
|
654
|
-
assertBundleContained(root, Object.keys(result.metafile?.inputs ?? {}),
|
|
671
|
+
assertBundleContained(root, Object.keys(result.metafile?.inputs ?? {}), contained);
|
|
655
672
|
const source = out.text;
|
|
656
673
|
const bytes = Buffer.byteLength(source, "utf8");
|
|
657
674
|
if (bytes > MAX_SOURCE_BYTES) {
|
|
658
|
-
|
|
675
|
+
let raw = null;
|
|
676
|
+
if (minify) {
|
|
677
|
+
try {
|
|
678
|
+
const rawOut = (await build(false)).outputFiles?.[0];
|
|
679
|
+
if (rawOut) raw = Buffer.byteLength(rawOut.text, "utf8");
|
|
680
|
+
} catch {
|
|
681
|
+
raw = null;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
throw new Error(formatBundleTooLarge(entryPath, bytes, raw, minify));
|
|
659
685
|
}
|
|
660
686
|
return { source, bytes };
|
|
661
687
|
}
|
|
688
|
+
function kb(n) {
|
|
689
|
+
return `${Math.round(n / 100) / 10} KB`;
|
|
690
|
+
}
|
|
691
|
+
function formatBundleTooLarge(entryPath, bytes, rawBytes, minified) {
|
|
692
|
+
const measured = minified ? `${kb(bytes)} minified` : kb(bytes);
|
|
693
|
+
const raw = rawBytes !== null && minified ? ` (${kb(rawBytes)} unminified)` : "";
|
|
694
|
+
return `bundled ${entryPath} is ${measured}${raw} \u2014 exceeds the ${MAX_SOURCE_BYTES / 1e3} KB deploy cap (the cap is measured on the minified bundle, which is what gets uploaded; trim imports or split the function)`;
|
|
695
|
+
}
|
|
662
696
|
function stableStringify(v) {
|
|
663
697
|
if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
|
|
664
698
|
if (v !== null && typeof v === "object") {
|
|
@@ -707,7 +741,7 @@ var DENY_FUNCTION_SCOPES = /* @__PURE__ */ new Set(["admin", "*", "features:writ
|
|
|
707
741
|
function clampFunctionScopes(scopes) {
|
|
708
742
|
return (scopes ?? []).map(String).filter((s) => s && !DENY_FUNCTION_SCOPES.has(s));
|
|
709
743
|
}
|
|
710
|
-
async function planFunctions({ api, functions, cwd, apply }) {
|
|
744
|
+
async function planFunctions({ api, functions, cwd, apply, onNote }) {
|
|
711
745
|
const read = await readRemote(api);
|
|
712
746
|
if (read.unavailable) {
|
|
713
747
|
if (apply) {
|
|
@@ -715,14 +749,14 @@ async function planFunctions({ api, functions, cwd, apply }) {
|
|
|
715
749
|
`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`
|
|
716
750
|
);
|
|
717
751
|
}
|
|
718
|
-
return { changes: [], applied: 0, cmsHookSubscriptions: null, remoteUnavailable: read.unavailable };
|
|
752
|
+
return { changes: [], applied: 0, cmsHookSubscriptions: null, webhookSubscriptions: null, remoteUnavailable: read.unavailable };
|
|
719
753
|
}
|
|
720
754
|
const remote = read.map;
|
|
721
755
|
const results = await mapPool(Object.entries(functions), 1, async ([name, def]) => {
|
|
722
756
|
if (!/^[a-z][a-z0-9-]{0,47}$/.test(name)) {
|
|
723
757
|
throw new Error(`functions: invalid name '${name}' (must match /^[a-z][a-z0-9-]{0,47}$/)`);
|
|
724
758
|
}
|
|
725
|
-
const { source, bytes } = await bundleFunction(resolve2(cwd, def.entry), { projectRoot: cwd });
|
|
759
|
+
const { source, bytes } = await bundleFunction(resolve2(cwd, def.entry), { projectRoot: cwd, ...onNote ? { onNote } : {} });
|
|
726
760
|
const triggerKind = def.trigger?.kind ?? "http";
|
|
727
761
|
const secrets = normalizeSecretRefs(def.secrets);
|
|
728
762
|
const bindings = lowerTriggerBindings(def.trigger);
|
|
@@ -731,7 +765,7 @@ async function planFunctions({ api, functions, cwd, apply }) {
|
|
|
731
765
|
const sha = sourceSha12(source);
|
|
732
766
|
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" : ""}]`;
|
|
733
767
|
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)) {
|
|
734
|
-
return { change: { name, kind: "unchanged", detail: label }, applied: 0, cmsHooks: null };
|
|
768
|
+
return { change: { name, kind: "unchanged", detail: label }, applied: 0, cmsHooks: null, webhooks: null };
|
|
735
769
|
}
|
|
736
770
|
const change = { name, kind: remoteFn === void 0 ? "deploy-new" : "deploy-update", detail: label };
|
|
737
771
|
if (apply) {
|
|
@@ -746,24 +780,64 @@ async function planFunctions({ api, functions, cwd, apply }) {
|
|
|
746
780
|
const e = res.body.error ?? {};
|
|
747
781
|
throw new Error(`functions: deploy ${name} failed: ${e.code ?? res.status} ${e.message ?? ""} ${e.hint ?? ""}`);
|
|
748
782
|
}
|
|
749
|
-
const
|
|
750
|
-
|
|
783
|
+
const data2 = res.body.data;
|
|
784
|
+
const cmsHooks = data2?.cms_hook_subscriptions ?? null;
|
|
785
|
+
const webhooks = data2?.webhook_subscriptions ?? null;
|
|
786
|
+
const warnings2 = (data2?.warnings ?? []).map((message) => ({ fn: name, message: String(message) }));
|
|
787
|
+
return { change, applied: 1, cmsHooks, webhooks, warnings: warnings2 };
|
|
751
788
|
}
|
|
752
|
-
return { change, applied: 0, cmsHooks: null };
|
|
789
|
+
return { change, applied: 0, cmsHooks: null, webhooks: null, warnings: [] };
|
|
753
790
|
});
|
|
754
791
|
const changes = results.map((r) => r.change);
|
|
755
792
|
const applied = results.reduce((s, r) => s + r.applied, 0);
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
793
|
+
const sum = (pick) => results.reduce((acc, r) => {
|
|
794
|
+
const v = pick(r);
|
|
795
|
+
if (!v) return acc;
|
|
796
|
+
return { created: (acc?.created ?? 0) + v.created, deleted: (acc?.deleted ?? 0) + v.deleted };
|
|
759
797
|
}, null);
|
|
760
|
-
|
|
798
|
+
const cmsHookSubscriptions = sum((r) => r.cmsHooks);
|
|
799
|
+
const webhookSubscriptions = sum((r) => r.webhooks);
|
|
800
|
+
const warnings = results.flatMap((r) => r.warnings ?? []);
|
|
801
|
+
return { changes, applied, cmsHookSubscriptions, webhookSubscriptions, ...warnings.length ? { warnings } : {} };
|
|
761
802
|
}
|
|
762
803
|
function formatFnChanges(changes) {
|
|
763
804
|
const actionable = changes.filter((c) => c.kind !== "unchanged");
|
|
764
805
|
if (!actionable.length) return " (no function changes)";
|
|
765
806
|
return actionable.map((c) => ` ${c.kind === "deploy-new" ? "+" : "~"} ${c.detail}`).join("\n");
|
|
766
807
|
}
|
|
808
|
+
async function probeFunctionsEntitlement(api, name) {
|
|
809
|
+
const res = await api("POST", `/v1/functions/${encodeURIComponent(name)}`, {});
|
|
810
|
+
if (res.status === 402 || res.status === 403) {
|
|
811
|
+
const e = res.body.error ?? {};
|
|
812
|
+
return {
|
|
813
|
+
entitled: false,
|
|
814
|
+
status: res.status,
|
|
815
|
+
...e.code ? { code: e.code } : {},
|
|
816
|
+
...e.message ? { message: e.message } : {},
|
|
817
|
+
...e.hint ? { hint: e.hint } : {},
|
|
818
|
+
...e.fixUrl ? { fixUrl: e.fixUrl } : {}
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
return { entitled: true };
|
|
822
|
+
}
|
|
823
|
+
function billingPageFor(tenantId, baseUrl) {
|
|
824
|
+
const tid = tenantId && tenantId !== "(unknown)" ? tenantId : "<tenant>";
|
|
825
|
+
const path = `/dashboard/${tid}/billing`;
|
|
826
|
+
try {
|
|
827
|
+
if (/(^|\.)api\.vxil\.com$/.test(new URL(baseUrl).hostname)) return `https://vxil.com${path}`;
|
|
828
|
+
} catch {
|
|
829
|
+
}
|
|
830
|
+
return path;
|
|
831
|
+
}
|
|
832
|
+
function formatFunctionsPreflight(e, opts) {
|
|
833
|
+
const n = `${opts.declared} declared function(s)`;
|
|
834
|
+
if (e.code === "sandbox_restricted" || e.status === 403) {
|
|
835
|
+
return `functions: this project cannot deploy functions (${e.code ?? e.status}${e.message ? ` \u2014 ${e.message}` : ""}); ${n} would be refused.${e.hint ? ` ${e.hint}` : ""}` + (opts.verb === "push" ? " Run `vxil push --skip-functions` to push everything else." : "");
|
|
836
|
+
}
|
|
837
|
+
const plan = /'([a-z]+)' plan/.exec(e.message ?? "")?.[1];
|
|
838
|
+
const planLabel = plan ? `the ${plan[0].toUpperCase()}${plan.slice(1)} plan` : "a paid plan";
|
|
839
|
+
return `functions need ${planLabel}${e.message ? ` (${e.message})` : ""} \u2014 ${n} would be refused with 402 tier_required. Upgrade in the dashboard (${billingPageFor(opts.tenantId, opts.baseUrl)})` + (opts.verb === "push" ? ", or run `vxil push --skip-functions` to push the cms schema and feature config without them" : "") + ".";
|
|
840
|
+
}
|
|
767
841
|
function formatLogLines(lines) {
|
|
768
842
|
const out = [];
|
|
769
843
|
for (const l of lines) {
|
|
@@ -1174,11 +1248,7 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
|
|
|
1174
1248
|
async function applyDestructive(api, collections, c) {
|
|
1175
1249
|
const ok2xx = (status) => status >= 200 && status < 300;
|
|
1176
1250
|
if (c.op === "drop-collection") {
|
|
1177
|
-
|
|
1178
|
-
if (!ok2xx(res2.status) && res2.body.error?.code !== "not_found") {
|
|
1179
|
-
const e = res2.body.error ?? {};
|
|
1180
|
-
throw new Error(`cms: drop collection ${c.collection} failed: ${e.code ?? res2.status} ${e.message ?? ""}`);
|
|
1181
|
-
}
|
|
1251
|
+
await dropCollection(api, c.collection);
|
|
1182
1252
|
return 1;
|
|
1183
1253
|
}
|
|
1184
1254
|
const path = `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`;
|
|
@@ -1217,6 +1287,53 @@ function formatCmsChanges(changes) {
|
|
|
1217
1287
|
return ` + ${c.detail}`;
|
|
1218
1288
|
}).join("\n");
|
|
1219
1289
|
}
|
|
1290
|
+
async function planCollectionDrop(api, collection) {
|
|
1291
|
+
const read = await readRemote2(api);
|
|
1292
|
+
if (read.unavailable) return { exists: false, fields: 0, rows: null, remoteUnavailable: read.unavailable };
|
|
1293
|
+
const rc = read.map.get(collection);
|
|
1294
|
+
if (!rc) return { exists: false, fields: 0, rows: null };
|
|
1295
|
+
return { exists: true, fields: rc.fields.length, rows: await liveRowCount(api, collection) };
|
|
1296
|
+
}
|
|
1297
|
+
async function dropCollection(api, collection) {
|
|
1298
|
+
const res = await api("POST", "/v1/cms/collections", { drop_collection: collection, allow_destructive: true });
|
|
1299
|
+
if ((res.status < 200 || res.status >= 300) && res.body.error?.code !== "not_found") {
|
|
1300
|
+
const e = res.body.error ?? {};
|
|
1301
|
+
throw new Error(`cms: drop collection ${collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
function formatCollectionDrop(collection, plan, declaredLocally) {
|
|
1305
|
+
const rows = plan.rows === null ? "an unknown number of live rows" : `${plan.rows} live row(s)`;
|
|
1306
|
+
const lines = [
|
|
1307
|
+
` ! drop collection ${collection} (${plan.fields} field(s), ${rows}) \u2014 the definition is removed; stored item rows are NOT deleted (they become unreachable through the API until a collection of that name exists again)`
|
|
1308
|
+
];
|
|
1309
|
+
if (declaredLocally) {
|
|
1310
|
+
lines.push(` \u26A0 '${collection}' is still declared in vxil.config.ts \u2014 the next \`vxil push\` re-creates it (empty); remove it from the config too`);
|
|
1311
|
+
}
|
|
1312
|
+
return lines.join("\n");
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// src/apiCmd.ts
|
|
1316
|
+
var API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
1317
|
+
var API_USAGE = "usage: vxil api <GET|POST|PUT|PATCH|DELETE> </v1/...> ['<json body>' | --data '<json body>']";
|
|
1318
|
+
function parseApiArgs(positionals, dataFlag) {
|
|
1319
|
+
const [rawMethod, path, ...extra] = positionals;
|
|
1320
|
+
const method = (rawMethod ?? "").toUpperCase();
|
|
1321
|
+
if (!rawMethod || !API_METHODS.includes(method)) throw new Error(API_USAGE);
|
|
1322
|
+
if (!path || !path.startsWith("/")) throw new Error(API_USAGE);
|
|
1323
|
+
const positionalBody = extra[0];
|
|
1324
|
+
if (extra.length > 1) throw new Error(`${API_USAGE} \u2014 too many arguments (${extra.length} after the path; quote the JSON body as ONE argument)`);
|
|
1325
|
+
if (positionalBody !== void 0 && dataFlag !== void 0) {
|
|
1326
|
+
throw new Error("pass the body either as the third argument or with --data, not both");
|
|
1327
|
+
}
|
|
1328
|
+
const raw = positionalBody ?? dataFlag;
|
|
1329
|
+
if (raw === void 0) return { method, path };
|
|
1330
|
+
try {
|
|
1331
|
+
return { method, path, body: JSON.parse(raw) };
|
|
1332
|
+
} catch {
|
|
1333
|
+
const via = positionalBody !== void 0 ? "the body argument" : "--data";
|
|
1334
|
+
throw new Error(`${via} must be valid JSON (got ${JSON.stringify(raw.length > 60 ? `${raw.slice(0, 57)}\u2026` : raw)})`);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1220
1337
|
|
|
1221
1338
|
// ../types/src/ulid.ts
|
|
1222
1339
|
var ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
@@ -5060,7 +5177,13 @@ var TOOLS = [
|
|
|
5060
5177
|
},
|
|
5061
5178
|
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."
|
|
5062
5179
|
},
|
|
5063
|
-
user_id: { type: "string", description: "Opaque end_user_id for per-user usage metering." }
|
|
5180
|
+
user_id: { type: "string", description: "Opaque end_user_id for per-user usage metering." },
|
|
5181
|
+
correlation_id: {
|
|
5182
|
+
type: "string",
|
|
5183
|
+
minLength: 1,
|
|
5184
|
+
maxLength: 128,
|
|
5185
|
+
description: "Your own opaque handle for this generation (1..128 chars): stored on the row and echoed on the answer; on mode:job it also rides the job.generation.completed|failed events next to generation_id, so you never need a run\u2192record link."
|
|
5186
|
+
}
|
|
5064
5187
|
}
|
|
5065
5188
|
},
|
|
5066
5189
|
method: "POST",
|
|
@@ -5233,6 +5356,8 @@ var TOOLS = [
|
|
|
5233
5356
|
event_type: { type: "string", description: "Exact event type filter, e.g. 'subscription.updated'." },
|
|
5234
5357
|
outcome: { type: "string", enum: ["received", "processed", "error", "sig_failed", "parse_failed", "reprocessed", "ignored", "unowned", "rejected_environment"] },
|
|
5235
5358
|
environment: { type: "string", enum: ["production", "sandbox"], description: "The provider-reported environment of the delivery (a separate axis from the API key label)." },
|
|
5359
|
+
provider_charge_id: { type: "string", maxLength: 200, description: "Exact match on the normalised charge id (the same provider_charge_id a payments.charge.* event carries): the delivery that recorded that charge plus any refund/dispute against it \u2014 usually one row, so ONE detail read (GET /v1/payments/webhook-events/{eventId}) fetches the verified payload. Rows received before 2026-09-19 carry no charge id." },
|
|
5360
|
+
provider_evt_id: { type: "string", maxLength: 200, description: "Exact match on the provider's own event id (the provider_evt_id every row shows)." },
|
|
5236
5361
|
since: { type: "string", description: "ISO-8601 lower bound on received_at." },
|
|
5237
5362
|
cursor: { type: "string", description: "Opaque keyset cursor from a prior page (next_cursor)." },
|
|
5238
5363
|
limit: { type: "number", default: 50, description: "Page size, clamped 1..100." }
|
|
@@ -5240,7 +5365,7 @@ var TOOLS = [
|
|
|
5240
5365
|
},
|
|
5241
5366
|
method: "GET",
|
|
5242
5367
|
path: "/v1/payments/webhook-events",
|
|
5243
|
-
queryArgs: ["provider", "event_type", "outcome", "environment", "since", "cursor", "limit"]
|
|
5368
|
+
queryArgs: ["provider", "event_type", "outcome", "environment", "provider_charge_id", "provider_evt_id", "since", "cursor", "limit"]
|
|
5244
5369
|
},
|
|
5245
5370
|
{
|
|
5246
5371
|
name: "mcp_get_signing_secret",
|
|
@@ -5755,6 +5880,10 @@ function partitionConfigChanges(feature, changes, patterns) {
|
|
|
5755
5880
|
for (const c of changes) (isIgnored(`config:${feature}.${c.path}`, patterns) ? ignored : kept).push(c);
|
|
5756
5881
|
return { kept, ignored };
|
|
5757
5882
|
}
|
|
5883
|
+
function unstoredSecretVerdict(opts) {
|
|
5884
|
+
if (opts.ignored) return "ignored";
|
|
5885
|
+
return opts.strict ? "drift" : "informational";
|
|
5886
|
+
}
|
|
5758
5887
|
function driftExitCode(r) {
|
|
5759
5888
|
if (r.errors > 0) return 2;
|
|
5760
5889
|
return r.drift > 0 ? 1 : 0;
|
|
@@ -5811,14 +5940,15 @@ function secretsChecks(refs, stored) {
|
|
|
5811
5940
|
}
|
|
5812
5941
|
return out;
|
|
5813
5942
|
}
|
|
5814
|
-
var MOCK_PROVIDER_NOTE = "notifications provider is 'mock': sends succeed and are recorded, but NO email leaves vxil (sign-in codes, magic links and password resets are silently swallowed). Store your provider key (`vxil secrets set notifications/resend_api_key`) and set provider: 'resend' + resendApiKeyRef in your notifications config before you rely on delivery";
|
|
5943
|
+
var MOCK_PROVIDER_NOTE = "notifications provider is 'mock': sends succeed and are recorded, but NO email leaves vxil (sign-in codes, magic links and password resets are silently swallowed). Store your provider key (`vxil secrets set notifications/resend_api_key`) and set provider: 'resend' + resendApiKeyRef in your notifications config before you rely on delivery (or, for Amazon SES: store notifications/ses_access_key_id + notifications/ses_secret_access_key and set provider: 'ses' + ses: { region, accessKeyIdRef, secretAccessKeyRef })";
|
|
5815
5944
|
function notificationsProviderChecks(manifest) {
|
|
5816
5945
|
if (!manifest || manifest.enabled === false) return [];
|
|
5817
5946
|
if (manifest.provider !== "mock") {
|
|
5947
|
+
const region = manifest.provider === "ses" && manifest.ses?.region ? ` (${manifest.ses.region})` : "";
|
|
5818
5948
|
return [{
|
|
5819
5949
|
name: "notifications provider",
|
|
5820
5950
|
ok: true,
|
|
5821
|
-
detail: `${String(manifest.provider ?? "resend")}${manifest.fromEmail ? ` \xB7 from ${manifest.fromEmail}` : ""}`
|
|
5951
|
+
detail: `${String(manifest.provider ?? "resend")}${region}${manifest.fromEmail ? ` \xB7 from ${manifest.fromEmail}` : ""}`
|
|
5822
5952
|
}];
|
|
5823
5953
|
}
|
|
5824
5954
|
return [{ name: "notifications provider", ok: true, warn: true, detail: MOCK_PROVIDER_NOTE }];
|
|
@@ -5969,6 +6099,20 @@ async function linkViaDeviceFlow(dash, slug, io) {
|
|
|
5969
6099
|
}
|
|
5970
6100
|
throw new Error("link timed out waiting for browser approval \u2014 run `vxil link <slug>` again");
|
|
5971
6101
|
}
|
|
6102
|
+
async function linkViaStoredKey(api, slug, storedKey) {
|
|
6103
|
+
let res;
|
|
6104
|
+
try {
|
|
6105
|
+
res = await api("GET", "/v1/features");
|
|
6106
|
+
} catch (e2) {
|
|
6107
|
+
return { invalid: `could not reach the edge (${e2.message})` };
|
|
6108
|
+
}
|
|
6109
|
+
if (res.status === 200) {
|
|
6110
|
+
const tenantId = res.body.meta?.tenant_id;
|
|
6111
|
+
return { result: { slug, tenant_id: tenantId && tenantId.length ? tenantId : "(unknown)", api_key: storedKey } };
|
|
6112
|
+
}
|
|
6113
|
+
const e = res.body.error;
|
|
6114
|
+
return { invalid: `${e?.code ?? res.status}${e?.message ? ` \u2014 ${e.message}` : ""}` };
|
|
6115
|
+
}
|
|
5972
6116
|
|
|
5973
6117
|
// src/secretsValue.ts
|
|
5974
6118
|
var ARGV_SECRET_WARNING = `WARNING: --value <v> puts the secret on the command line \u2014 it lands in shell history and is
|
|
@@ -6490,7 +6634,7 @@ function detectOwner(table, source, authTable, opts) {
|
|
|
6490
6634
|
for (const p of source.rlsPolicies ?? []) {
|
|
6491
6635
|
if (lastSeg(p.table) !== table.name) continue;
|
|
6492
6636
|
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);
|
|
6493
|
-
if (m && cols.has(m[1])) return { column: m[1], via: `
|
|
6637
|
+
if (m && cols.has(m[1])) return { column: m[1], via: `row-level access rule${p.name ? ` '${p.name}'` : ""}` };
|
|
6494
6638
|
}
|
|
6495
6639
|
for (const fk of source.foreignKeys) {
|
|
6496
6640
|
if (fk.childTable !== table.name || fk.childColumns.length !== 1) continue;
|
|
@@ -6829,7 +6973,7 @@ function inferMapping(source, opts = {}) {
|
|
|
6829
6973
|
}
|
|
6830
6974
|
if ((source.rlsPolicies?.length ?? 0) > 0) {
|
|
6831
6975
|
residuals.push(
|
|
6832
|
-
`R15: ${source.rlsPolicies.length}
|
|
6976
|
+
`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`
|
|
6833
6977
|
);
|
|
6834
6978
|
}
|
|
6835
6979
|
if ((source.buckets?.length ?? 0) > 0) {
|
|
@@ -7874,7 +8018,7 @@ var STANDING_CAVEATS = [
|
|
|
7874
8018
|
"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.",
|
|
7875
8019
|
"Push device tokens: no vxil landing (no push channel) \u2014 clients re-enroll post-cutover.",
|
|
7876
8020
|
"Subscriptions / entitlements / tier: NEVER seeded (provider-derived truth) \u2014 rebuild from your live provider webhooks after cutover.",
|
|
7877
|
-
"DB triggers / RPCs / pg_cron /
|
|
8021
|
+
"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.",
|
|
7878
8022
|
"File objects: re-keyed (new object_id; source paths/URLs not preserved) \u2014 mint downloadUrl at read time, never persist it."
|
|
7879
8023
|
];
|
|
7880
8024
|
function emitResidualsMd(plan, sourceLabel) {
|
|
@@ -8748,7 +8892,7 @@ export default defineConfig({
|
|
|
8748
8892
|
"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',
|
|
8749
8893
|
"functions": {
|
|
8750
8894
|
"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",
|
|
8751
|
-
"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",
|
|
8895
|
+
"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",
|
|
8752
8896
|
"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"
|
|
8753
8897
|
}
|
|
8754
8898
|
},
|
|
@@ -8779,8 +8923,8 @@ export default defineConfig({
|
|
|
8779
8923
|
"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',
|
|
8780
8924
|
"functions": {
|
|
8781
8925
|
"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",
|
|
8782
|
-
"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",
|
|
8783
|
-
"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"
|
|
8926
|
+
"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",
|
|
8927
|
+
"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"
|
|
8784
8928
|
}
|
|
8785
8929
|
},
|
|
8786
8930
|
{
|
|
@@ -9051,7 +9195,7 @@ export default defineConfig({
|
|
|
9051
9195
|
"functions": {
|
|
9052
9196
|
"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",
|
|
9053
9197
|
"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",
|
|
9054
|
-
"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",
|
|
9198
|
+
"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",
|
|
9055
9199
|
"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"
|
|
9056
9200
|
}
|
|
9057
9201
|
},
|
|
@@ -9332,7 +9476,7 @@ export default defineConfig({
|
|
|
9332
9476
|
`,
|
|
9333
9477
|
"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",
|
|
9334
9478
|
"functions": {
|
|
9335
|
-
"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",
|
|
9479
|
+
"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",
|
|
9336
9480
|
"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"
|
|
9337
9481
|
}
|
|
9338
9482
|
},
|
|
@@ -9353,8 +9497,8 @@ export default defineConfig({
|
|
|
9353
9497
|
],
|
|
9354
9498
|
"hasFunctions": false,
|
|
9355
9499
|
"byoKeys": [],
|
|
9356
|
-
"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// \"Sales CRM\" \u2014 companies \u2192 contacts \u2192 a deal pipeline, declared end-to-end in\n// ONE typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 companies \u2192 contacts \u2192 deals (resolved by relation), with a\n// Lane-A STATE MACHINE guarding the pipeline stage\n// \u2022 activity-feed \u2192 the per-deal timeline (calls, emails, notes) + the rep bell\n// \u2022 notifications \u2192 the email channel (mock provider until you bring a key)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // THE PIPELINE STATE MACHINE (Lane-A validate \u2014 runs inside the write\n // transaction): a deal may only move lead \u2192 qualified \u2192 proposal \u2192\n // won | lost. Any other transition is a clean 422, rolled back\n // atomically. Cross-row work (e.g. \"email the rep on won\") belongs in\n // a function or webhook, never in a hook \u2014 hooks are pure row logic.\n deal_stage_machine: {\n collection: 'deals',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.stage == before.stage' +\n \" || (before.stage == 'lead' && item.stage == 'qualified')\" +\n \" || (before.stage == 'qualified' && item.stage == 'proposal')\" +\n \" || (before.stage == 'proposal' && (item.stage == 'won' || item.stage == 'lost'))\",\n message: 'illegal pipeline transition (lead \u2192 qualified \u2192 proposal \u2192 won|lost)',\n },\n },\n },\n\n // The per-deal timeline. The DEFAULT feed groups already fit a CRM:\n // `timeline` (flat, chronological) keyed per deal \u2014 ingest with\n // POST /v1/feeds/timeline/{deal_item_id}/activities { actor, verb, object }\n // \u2014 and the aggregated `notification` bell per rep.\n 'activity-feed': {},\n\n // Email channel. `mock` is the zero-config staging provider; for real\n // sends switch to provider: 'resend' and set `resendApiKeyRef`.\n notifications: { provider: 'mock', fromEmail: 'noreply@crm.app' },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n companies: {\n singular: 'company',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n // Want `domain` unique? Declare `unique: true` right here \u2014 value\n // uniqueness (cms.md \xA79.3) is carried by `vxil push` on collection/field\n // CREATE. Flipping it on an ALREADY-pushed field is an in-place alter\n // via the dashboard field designer or a same-name+type re-add through\n // POST /v1/cms/collections/companies/fields (push diffs name+type only).\n domain: { type: 'string', indexSlot: 's2' },\n industry: { type: 'string', indexSlot: 's3' },\n employees: { type: 'int', indexSlot: 'n1' },\n },\n },\n contacts: {\n singular: 'contact',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n email: { type: 'string', indexSlot: 's2' }, // unique \u21D2 see the `domain` note above\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n last_touch: { type: 'datetime', indexSlot: 't1' },\n phone: { type: 'string' }, // stored, not indexed\n },\n },\n deals: {\n singular: 'deal',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n // The pipeline stage: enum-validated on every write; the Lane-A hook\n // above additionally locks the TRANSITIONS between stages.\n stage: {\n type: 'string',\n required: true,\n indexSlot: 's2',\n validation: { enum: ['lead', 'qualified', 'proposal', 'won', 'lost'] },\n },\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n owner: { type: 'string', indexSlot: 's4' }, // the rep working the deal\n amount_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n close_date: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n },\n },\n\n // `vxil seed` POSTs each item verbatim \u2014 item_ids are assigned at runtime, so\n // the `company` relation on contacts/deals is left unset here; link records\n // after seeding (PATCH the created items with the company's item_id).\n seed: {\n cms: [\n {\n collection: 'companies',\n items: [{ name: 'Acme Corp', domain: 'acme.com', industry: 'Manufacturing', employees: 250 }],\n },\n {\n collection: 'contacts',\n items: [{ name: 'Jane Porter', email: 'jane@acme.com', phone: '+1 555 0100' }],\n },\n {\n collection: 'deals',\n items: [\n { title: 'Acme starter plan', stage: 'lead', owner: 'jordan', amount_cents: 480_000 },\n { title: 'Acme enterprise rollout', stage: 'proposal', owner: 'sam', amount_cents: 12_000_000 },\n ],\n },\n ],\n },\n});\n",
|
|
9357
|
-
"readme": '# Sales CRM template\n\nA sales CRM backend \u2014 companies, contacts, and a deal pipeline \u2014 declared end-to-end in one typed\n`vxil.config.ts`. Stage transitions are guarded by a Lane-A state machine that runs inside the write\ntransaction, every deal gets an activity timeline via `activity-feed`, and `notifications` provides\nthe email channel (mock provider until you bring your own key).\n\n**What it provisions:**\n- `companies` \u2014 name, domain, industry, employee count (slot-bound for range/sort). To make\n `domain`/`email` unique, declare `unique: true` on the field \u2014 carried by `vxil push` on create\n (`cms.md` \xA79.3); flipping it on an already-pushed field is a dashboard/REST in-place alter.\n- `contacts` \u2014 name, email, `company` relation, `last_touch` datetime.\n- `deals` \u2014 title, enum-validated `stage`, `company` relation, `owner` (the rep), `amount_cents`,\n `close_date`. A Lane-A `validate` hook locks the pipeline: `lead \u2192 qualified \u2192 proposal \u2192 won|lost`.\n- Features: `cms` + `activity-feed` (per-deal timelines + the rep notification bell, default feed\n groups) + `notifications` (mock provider; swap to `resend` + `resendApiKeyRef` for real sends).\n\n**Apply it:**\n\n```bash\nvxil init --template crm\nvxil quickstart # or `vxil link` to an existing tenant\nvxil push\nvxil gen # then optionally: `vxil seed` (1 company, 1 contact, 2 deals)\n```\n\n**What to learn from this:**\n1. **The pipeline state machine** \u2014 a `validate` hook on `beforeUpdate` compares `item.stage` to\n `before.stage` and rejects any illegal transition with a clean 422, atomically in the same write.\n Field-level `validation.enum` handles membership; the hook handles the *transitions*.\n2. **Pipeline value by stage** \u2014 one bounded group-by aggregate (`stage` is slot-bound, `amount_cents`\n is an `n*` slot), no SQL:\n ```bash\n curl -X POST https://api.vxil.com/v1/cms/items/deals/aggregate \\\n -H "Authorization: Bearer $VXIL_KEY" -H \'content-type: application/json\' \\\n -d \'{"aggregates":[{"fn":"sum","field":"amount_cents","as":"pipeline_cents"},{"fn":"count"}],"groupBy":["stage"]}\'\n # \u2192 { "groups": [{ "key": {"stage":"proposal"}, "count": 1, "pipeline_cents": 12000000 }, \u2026], "scanned": \u2026 }\n ```\n3. **The deal timeline** \u2014 each call/email/note is one ingest to the default `timeline` feed group:\n `POST /v1/feeds/timeline/{deal_item_id}/activities` with `{ actor, verb, object }` (idempotent on\n `foreign_id` + `time`); read it back with keyset cursors, or typed via `vx.from(\'deals\').query(\u2026)`\n for the pipeline board itself.\n\n**Go deeper:** vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (aggregates), vxil.com/docs/guide/06-feature-catalog (activity-feed,\nnotifications), and `examples/ecommerce/` for a bigger state machine in the wild.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
|
|
9500
|
+
"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// \"Sales CRM\" \u2014 companies \u2192 contacts \u2192 a deal pipeline, declared end-to-end in\n// ONE typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 companies \u2192 contacts \u2192 deals (resolved by relation), with a\n// Lane-A STATE MACHINE guarding the pipeline stage\n// \u2022 activity-feed \u2192 the per-deal timeline (calls, emails, notes) + the rep bell\n// \u2022 notifications \u2192 the email channel (mock provider until you bring a key)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // THE PIPELINE STATE MACHINE (Lane-A validate \u2014 runs inside the write\n // transaction): a deal may only move lead \u2192 qualified \u2192 proposal \u2192\n // won | lost. Any other transition is a clean 422, rolled back\n // atomically. Cross-row work (e.g. \"email the rep on won\") belongs in\n // a function or webhook, never in a hook \u2014 hooks are pure row logic.\n deal_stage_machine: {\n collection: 'deals',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.stage == before.stage' +\n \" || (before.stage == 'lead' && item.stage == 'qualified')\" +\n \" || (before.stage == 'qualified' && item.stage == 'proposal')\" +\n \" || (before.stage == 'proposal' && (item.stage == 'won' || item.stage == 'lost'))\",\n message: 'illegal pipeline transition (lead \u2192 qualified \u2192 proposal \u2192 won|lost)',\n },\n },\n },\n\n // The per-deal timeline. The DEFAULT feed groups already fit a CRM:\n // `timeline` (flat, chronological) keyed per deal \u2014 ingest with\n // POST /v1/feeds/timeline/{deal_item_id}/activities { actor, verb, object }\n // \u2014 and the aggregated `notification` bell per rep.\n 'activity-feed': {},\n\n // Email channel. `mock` is the zero-config staging provider; for real\n // sends switch to provider: 'resend' and set `resendApiKeyRef`, or to\n // provider: 'ses' with `ses: { region, accessKeyIdRef, secretAccessKeyRef }`.\n notifications: { provider: 'mock', fromEmail: 'noreply@crm.app' },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n companies: {\n singular: 'company',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n // Want `domain` unique? Declare `unique: true` right here \u2014 value\n // uniqueness (cms.md \xA79.3) is carried by `vxil push` on collection/field\n // CREATE. Flipping it on an ALREADY-pushed field is an in-place alter\n // via the dashboard field designer or a same-name+type re-add through\n // POST /v1/cms/collections/companies/fields (push diffs name+type only).\n domain: { type: 'string', indexSlot: 's2' },\n industry: { type: 'string', indexSlot: 's3' },\n employees: { type: 'int', indexSlot: 'n1' },\n },\n },\n contacts: {\n singular: 'contact',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n email: { type: 'string', indexSlot: 's2' }, // unique \u21D2 see the `domain` note above\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n last_touch: { type: 'datetime', indexSlot: 't1' },\n phone: { type: 'string' }, // stored, not indexed\n },\n },\n deals: {\n singular: 'deal',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n // The pipeline stage: enum-validated on every write; the Lane-A hook\n // above additionally locks the TRANSITIONS between stages.\n stage: {\n type: 'string',\n required: true,\n indexSlot: 's2',\n validation: { enum: ['lead', 'qualified', 'proposal', 'won', 'lost'] },\n },\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n owner: { type: 'string', indexSlot: 's4' }, // the rep working the deal\n amount_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n close_date: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n },\n },\n\n // `vxil seed` POSTs each item verbatim \u2014 item_ids are assigned at runtime, so\n // the `company` relation on contacts/deals is left unset here; link records\n // after seeding (PATCH the created items with the company's item_id).\n seed: {\n cms: [\n {\n collection: 'companies',\n items: [{ name: 'Acme Corp', domain: 'acme.com', industry: 'Manufacturing', employees: 250 }],\n },\n {\n collection: 'contacts',\n items: [{ name: 'Jane Porter', email: 'jane@acme.com', phone: '+1 555 0100' }],\n },\n {\n collection: 'deals',\n items: [\n { title: 'Acme starter plan', stage: 'lead', owner: 'jordan', amount_cents: 480_000 },\n { title: 'Acme enterprise rollout', stage: 'proposal', owner: 'sam', amount_cents: 12_000_000 },\n ],\n },\n ],\n },\n});\n",
|
|
9501
|
+
"readme": '# Sales CRM template\n\nA sales CRM backend \u2014 companies, contacts, and a deal pipeline \u2014 declared end-to-end in one typed\n`vxil.config.ts`. Stage transitions are guarded by a Lane-A state machine that runs inside the write\ntransaction, every deal gets an activity timeline via `activity-feed`, and `notifications` provides\nthe email channel (mock provider until you bring your own key).\n\n**What it provisions:**\n- `companies` \u2014 name, domain, industry, employee count (slot-bound for range/sort). To make\n `domain`/`email` unique, declare `unique: true` on the field \u2014 carried by `vxil push` on create\n (`cms.md` \xA79.3); flipping it on an already-pushed field is a dashboard/REST in-place alter.\n- `contacts` \u2014 name, email, `company` relation, `last_touch` datetime.\n- `deals` \u2014 title, enum-validated `stage`, `company` relation, `owner` (the rep), `amount_cents`,\n `close_date`. A Lane-A `validate` hook locks the pipeline: `lead \u2192 qualified \u2192 proposal \u2192 won|lost`.\n- Features: `cms` + `activity-feed` (per-deal timelines + the rep notification bell, default feed\n groups) + `notifications` (mock provider; swap to `resend` + `resendApiKeyRef`, or `ses` +\n `ses: { region, accessKeyIdRef, secretAccessKeyRef }`, for real sends).\n\n**Apply it:**\n\n```bash\nvxil init --template crm\nvxil quickstart # or `vxil link` to an existing tenant\nvxil push\nvxil gen # then optionally: `vxil seed` (1 company, 1 contact, 2 deals)\n```\n\n**What to learn from this:**\n1. **The pipeline state machine** \u2014 a `validate` hook on `beforeUpdate` compares `item.stage` to\n `before.stage` and rejects any illegal transition with a clean 422, atomically in the same write.\n Field-level `validation.enum` handles membership; the hook handles the *transitions*.\n2. **Pipeline value by stage** \u2014 one bounded group-by aggregate (`stage` is slot-bound, `amount_cents`\n is an `n*` slot), no SQL:\n ```bash\n curl -X POST https://api.vxil.com/v1/cms/items/deals/aggregate \\\n -H "Authorization: Bearer $VXIL_KEY" -H \'content-type: application/json\' \\\n -d \'{"aggregates":[{"fn":"sum","field":"amount_cents","as":"pipeline_cents"},{"fn":"count"}],"groupBy":["stage"]}\'\n # \u2192 { "groups": [{ "key": {"stage":"proposal"}, "count": 1, "pipeline_cents": 12000000 }, \u2026], "scanned": \u2026 }\n ```\n3. **The deal timeline** \u2014 each call/email/note is one ingest to the default `timeline` feed group:\n `POST /v1/feeds/timeline/{deal_item_id}/activities` with `{ actor, verb, object }` (idempotent on\n `foreign_id` + `time`); read it back with keyset cursors, or typed via `vx.from(\'deals\').query(\u2026)`\n for the pipeline board itself.\n\n**Go deeper:** vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (aggregates), vxil.com/docs/guide/06-feature-catalog (activity-feed,\nnotifications), and `examples/ecommerce/` for a bigger state machine in the wild.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
|
|
9358
9502
|
"functions": {}
|
|
9359
9503
|
},
|
|
9360
9504
|
{
|
|
@@ -9379,7 +9523,7 @@ export default defineConfig({
|
|
|
9379
9523
|
"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",
|
|
9380
9524
|
"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',
|
|
9381
9525
|
"functions": {
|
|
9382
|
-
"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"
|
|
9526
|
+
"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"
|
|
9383
9527
|
}
|
|
9384
9528
|
},
|
|
9385
9529
|
{
|
|
@@ -10377,7 +10521,7 @@ var catalogSql = {
|
|
|
10377
10521
|
ORDER BY cl.relname, c.conname`,
|
|
10378
10522
|
params: [schemas]
|
|
10379
10523
|
}),
|
|
10380
|
-
/**
|
|
10524
|
+
/** Source row-level access rules verbatim (R15 residual; R6 owner-column grep source). */
|
|
10381
10525
|
rlsPolicies: (schemas) => ({
|
|
10382
10526
|
text: `
|
|
10383
10527
|
SELECT schemaname AS table_schema, tablename AS table_name,
|
|
@@ -11197,6 +11341,22 @@ async function runApply(apply, sel = "prod", opts = {}) {
|
|
|
11197
11341
|
process.exit(2);
|
|
11198
11342
|
}
|
|
11199
11343
|
const cwd = process.cwd();
|
|
11344
|
+
const declaredFns = Object.keys(cfg.functions ?? {});
|
|
11345
|
+
const skipFunctions = hasFlag("skip-functions");
|
|
11346
|
+
if (declaredFns.length && !skipFunctions && !gate) {
|
|
11347
|
+
const ent = await probeFunctionsEntitlement(api, declaredFns[0]);
|
|
11348
|
+
if (!ent.entitled) {
|
|
11349
|
+
const msg = formatFunctionsPreflight(ent, {
|
|
11350
|
+
declared: declaredFns.length,
|
|
11351
|
+
baseUrl: target.baseUrl,
|
|
11352
|
+
verb: apply ? "push" : "plan",
|
|
11353
|
+
...target.tenantId ? { tenantId: target.tenantId } : {}
|
|
11354
|
+
});
|
|
11355
|
+
if (apply) fail(`${msg}
|
|
11356
|
+
(nothing was pushed)`);
|
|
11357
|
+
console.error(`vxil: warning \u2014 ${msg}`);
|
|
11358
|
+
}
|
|
11359
|
+
}
|
|
11200
11360
|
const notCompared = (u, hint) => {
|
|
11201
11361
|
console.log(formatNotCompared(u, hint));
|
|
11202
11362
|
if (gate) report.errors++;
|
|
@@ -11267,6 +11427,11 @@ ${r.feature} (remote v${r.version}):`);
|
|
|
11267
11427
|
}
|
|
11268
11428
|
return;
|
|
11269
11429
|
}
|
|
11430
|
+
if (skipFunctions) {
|
|
11431
|
+
console.log(`
|
|
11432
|
+
functions: skipped (--skip-functions) \u2014 ${declaredFns.length} declared function(s) not ${apply ? "deployed" : "compared"}`);
|
|
11433
|
+
return;
|
|
11434
|
+
}
|
|
11270
11435
|
const r = await planFunctions({ api, functions: cfg.functions, cwd, apply });
|
|
11271
11436
|
console.log("\nfunctions:");
|
|
11272
11437
|
if (r.remoteUnavailable) {
|
|
@@ -11275,12 +11440,20 @@ ${r.feature} (remote v${r.version}):`);
|
|
|
11275
11440
|
}
|
|
11276
11441
|
console.log(formatFnChanges(r.changes));
|
|
11277
11442
|
if (apply && r.applied) console.log(` \u2713 deployed ${r.applied} function(s)`);
|
|
11443
|
+
for (const w of r.warnings ?? []) {
|
|
11444
|
+
console.error(` ! ${w.fn}: ${w.message}`);
|
|
11445
|
+
}
|
|
11278
11446
|
if (apply && r.cmsHookSubscriptions && (r.cmsHookSubscriptions.created || r.cmsHookSubscriptions.deleted)) {
|
|
11279
11447
|
console.log(` \u2713 cms-hook subscription reconciled (+${r.cmsHookSubscriptions.created} / -${r.cmsHookSubscriptions.deleted})`);
|
|
11280
11448
|
}
|
|
11449
|
+
if (apply && r.webhookSubscriptions && (r.webhookSubscriptions.created || r.webhookSubscriptions.deleted)) {
|
|
11450
|
+
console.log(` \u2713 webhook-trigger subscription reconciled (+${r.webhookSubscriptions.created} / -${r.webhookSubscriptions.deleted})`);
|
|
11451
|
+
}
|
|
11281
11452
|
for (const c of r.changes) if (c.kind !== "unchanged") count(`function:${c.name}`);
|
|
11282
11453
|
compared.functions += Object.keys(cfg.functions).length;
|
|
11283
11454
|
});
|
|
11455
|
+
const strictSecrets = hasFlag("strict-secrets");
|
|
11456
|
+
const informationalKeys = [];
|
|
11284
11457
|
if (!apply && (gate || explain)) {
|
|
11285
11458
|
await runSection("secrets", async () => {
|
|
11286
11459
|
const refs = collectSecretRefs(cfg);
|
|
@@ -11297,9 +11470,16 @@ ${r.feature} (remote v${r.version}):`);
|
|
|
11297
11470
|
const cmp = compareSecretNames(refs, stored);
|
|
11298
11471
|
if (!cmp.missing.length && !cmp.unreferenced.length) console.log(` (in sync \u2014 ${cmp.present.length} ref(s), all stored)`);
|
|
11299
11472
|
for (const m of cmp.missing) {
|
|
11300
|
-
const
|
|
11301
|
-
|
|
11302
|
-
|
|
11473
|
+
const key = `secret:${m.name}`;
|
|
11474
|
+
const verdict = unstoredSecretVerdict({ strict: strictSecrets, ignored: isIgnored(key, ignore) });
|
|
11475
|
+
const mark = verdict === "drift" ? "-" : "\xB7";
|
|
11476
|
+
console.log(` ${mark} ${key}${m.feature ? ` (${m.feature})` : ""} referenced at ${m.via} but NOT stored on the target` + (m.stored_elsewhere?.length ? ` (stored under ${m.stored_elsewhere.join(", ")} instead)` : "") + (verdict === "ignored" ? " \u2014 ignored" : verdict === "informational" ? " \u2014 informational (not counted; --strict-secrets counts it)" : ""));
|
|
11477
|
+
if (verdict === "informational") {
|
|
11478
|
+
report.informational = (report.informational ?? 0) + 1;
|
|
11479
|
+
informationalKeys.push(key);
|
|
11480
|
+
} else {
|
|
11481
|
+
count(key);
|
|
11482
|
+
}
|
|
11303
11483
|
}
|
|
11304
11484
|
for (const u of cmp.unreferenced) console.log(` \xB7 ${u.feature}/${u.secret_name} stored but not referenced by this config (informational)`);
|
|
11305
11485
|
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>`");
|
|
@@ -11350,14 +11530,19 @@ api state \u2014 ${self.target.tenantSlug ?? "(env key)"} vs ${other.target.tena
|
|
|
11350
11530
|
exit_code: code,
|
|
11351
11531
|
target: target.tenantSlug ?? null,
|
|
11352
11532
|
env: target.envLabel,
|
|
11353
|
-
|
|
11533
|
+
drift: report.drift,
|
|
11534
|
+
ignored: report.ignored,
|
|
11535
|
+
errors: report.errors,
|
|
11536
|
+
informational: report.informational ?? 0,
|
|
11354
11537
|
compared,
|
|
11355
11538
|
ignored_keys: ignoredKeys,
|
|
11539
|
+
informational_keys: informationalKeys,
|
|
11540
|
+
strict_secrets: strictSecrets,
|
|
11356
11541
|
...opts.apiState ? { api_state: apiStateCounts } : {}
|
|
11357
11542
|
}));
|
|
11358
11543
|
} else {
|
|
11359
11544
|
console.log(`
|
|
11360
|
-
vxil diff \u2192 ${report.drift} drift item(s), ${report.ignored} ignored, ${report.errors} error(s) \u2014 compared ${formatCompared(compared)} \u2014 exit ${code}`);
|
|
11545
|
+
vxil diff \u2192 ${report.drift} drift item(s), ${report.ignored} ignored, ${report.informational ?? 0} informational, ${report.errors} error(s) \u2014 compared ${formatCompared(compared)} \u2014 exit ${code}`);
|
|
11361
11546
|
}
|
|
11362
11547
|
process.exit(code);
|
|
11363
11548
|
}
|
|
@@ -11365,7 +11550,7 @@ vxil diff \u2192 ${report.drift} drift item(s), ${report.ignored} ignored, ${rep
|
|
|
11365
11550
|
if (apply && !hasFlag("no-gen")) await runGen(sel);
|
|
11366
11551
|
}
|
|
11367
11552
|
async function runServerApply(sel) {
|
|
11368
|
-
const { api } = requireApi(sel, { write: "push --server" });
|
|
11553
|
+
const { api, target } = requireApi(sel, { write: "push --server" });
|
|
11369
11554
|
const resume = flag("resume");
|
|
11370
11555
|
if (resume) {
|
|
11371
11556
|
const done2 = await serverApply({ api, resume });
|
|
@@ -11377,8 +11562,26 @@ apply ${done2.apply_id}: ${done2.status}`);
|
|
|
11377
11562
|
if (done2.status === "succeeded" && !hasFlag("no-gen")) await runGen(sel);
|
|
11378
11563
|
return;
|
|
11379
11564
|
}
|
|
11380
|
-
const
|
|
11565
|
+
const loaded = await loadVxilConfig();
|
|
11381
11566
|
const cwd = process.cwd();
|
|
11567
|
+
const { functions: declaredFns, ...rest2 } = loaded;
|
|
11568
|
+
const cfg = hasFlag("skip-functions") ? rest2 : loaded;
|
|
11569
|
+
const fnNames = Object.keys(cfg.functions ?? {});
|
|
11570
|
+
if (hasFlag("skip-functions") && declaredFns && Object.keys(declaredFns).length) {
|
|
11571
|
+
console.log(`functions: skipped (--skip-functions) \u2014 ${Object.keys(declaredFns).length} declared function(s) not deployed`);
|
|
11572
|
+
}
|
|
11573
|
+
if (fnNames.length) {
|
|
11574
|
+
const ent = await probeFunctionsEntitlement(api, fnNames[0]);
|
|
11575
|
+
if (!ent.entitled) {
|
|
11576
|
+
fail(`${formatFunctionsPreflight(ent, {
|
|
11577
|
+
declared: fnNames.length,
|
|
11578
|
+
baseUrl: target.baseUrl,
|
|
11579
|
+
verb: "push",
|
|
11580
|
+
...target.tenantId ? { tenantId: target.tenantId } : {}
|
|
11581
|
+
})}
|
|
11582
|
+
(nothing was applied)`);
|
|
11583
|
+
}
|
|
11584
|
+
}
|
|
11382
11585
|
const fnSources = {};
|
|
11383
11586
|
for (const [name, def] of Object.entries(cfg.functions ?? {})) {
|
|
11384
11587
|
fnSources[name] = (await bundleFunction(resolve7(cwd, def.entry), { projectRoot: cwd })).source;
|
|
@@ -11541,6 +11744,7 @@ async function runGen(sel = "prod", opts = {}) {
|
|
|
11541
11744
|
}
|
|
11542
11745
|
async function runWatch(sel) {
|
|
11543
11746
|
const cwd = process.cwd();
|
|
11747
|
+
await promotionGate(resolveTargetOrFail(sel), false);
|
|
11544
11748
|
console.log("vxil dev --watch: applying on save (Ctrl-C to stop)\u2026");
|
|
11545
11749
|
const apply = async () => {
|
|
11546
11750
|
try {
|
|
@@ -11950,7 +12154,7 @@ try {
|
|
|
11950
12154
|
}
|
|
11951
12155
|
case "link": {
|
|
11952
12156
|
const slug = positional(0)[0];
|
|
11953
|
-
if (!slug) fail("usage: vxil link <slug> [--key <api_key>] [--tenant <id>] [--as dev|<target-name>] [--env <label>]");
|
|
12157
|
+
if (!slug) fail("usage: vxil link <slug> [--key <api_key>] [--tenant <id>] [--as dev|<target-name>] [--env <label>] [--mint]");
|
|
11954
12158
|
const base = process.env.VXIL_BASE_URL ?? DEFAULT_BASE;
|
|
11955
12159
|
const directKey = flag("key");
|
|
11956
12160
|
if (directKey) {
|
|
@@ -11959,8 +12163,19 @@ try {
|
|
|
11959
12163
|
console.log(`\u2713 linked '${slug}' ${bound} (using provided --key)`);
|
|
11960
12164
|
break;
|
|
11961
12165
|
}
|
|
11962
|
-
const dash = makeDash(dashboardBase(base));
|
|
11963
12166
|
const creds = loadCredentials();
|
|
12167
|
+
const stored = creds.keys?.[slug];
|
|
12168
|
+
if (stored && !hasFlag("mint")) {
|
|
12169
|
+
const r = await linkViaStoredKey(makeApi({ apiKey: stored, baseUrl: base }), slug, stored);
|
|
12170
|
+
if ("result" in r) {
|
|
12171
|
+
const tenantId = flag("tenant") ?? r.result.tenant_id;
|
|
12172
|
+
const bound = persistLink(slug, tenantId, stored, base);
|
|
12173
|
+
console.log(`\u2713 linked '${slug}' ${bound} (using the key already stored for this slug in ~/.vxil/credentials.json \u2014 pass --mint for a new one)`);
|
|
12174
|
+
break;
|
|
12175
|
+
}
|
|
12176
|
+
console.error(`stored key for '${slug}' was refused (${r.invalid}) \u2014 minting a new one\u2026`);
|
|
12177
|
+
}
|
|
12178
|
+
const dash = makeDash(dashboardBase(base));
|
|
11964
12179
|
const keyName = `cli-${hostname()}`.slice(0, 100);
|
|
11965
12180
|
if (creds.session) {
|
|
11966
12181
|
const r = await linkViaSession(dash, creds.session, slug, keyName);
|
|
@@ -12345,6 +12560,35 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
|
|
|
12345
12560
|
}
|
|
12346
12561
|
case "cms": {
|
|
12347
12562
|
const [sub] = positional(0);
|
|
12563
|
+
if (sub === "drop") {
|
|
12564
|
+
const [collection] = positional(1);
|
|
12565
|
+
if (!collection) fail("usage: vxil cms drop <collection> [--yes]");
|
|
12566
|
+
const { api: api2, target } = requireApi(selector(), { write: `cms drop ${collection}` });
|
|
12567
|
+
const plan = await planCollectionDrop(api2, collection);
|
|
12568
|
+
if (plan.remoteUnavailable) {
|
|
12569
|
+
fail(`cannot read ${plan.remoteUnavailable.route} (${plan.remoteUnavailable.code ?? plan.remoteUnavailable.status}) \u2014 refusing to drop against an unread target`);
|
|
12570
|
+
}
|
|
12571
|
+
if (!plan.exists) fail(`collection '${collection}' does not exist on tenant '${target.tenantSlug ?? "(env key)"}' \u2014 nothing to drop`);
|
|
12572
|
+
let declaredLocally = false;
|
|
12573
|
+
try {
|
|
12574
|
+
const cfg = await loadVxilConfig();
|
|
12575
|
+
declaredLocally = Object.prototype.hasOwnProperty.call(cfg.cms?.collections ?? {}, collection);
|
|
12576
|
+
} catch {
|
|
12577
|
+
}
|
|
12578
|
+
console.error(formatCollectionDrop(collection, plan, declaredLocally));
|
|
12579
|
+
if (!hasFlag("yes")) {
|
|
12580
|
+
if (!process.stdin.isTTY) fail("cms drop is destructive \u2014 pass --yes to confirm in a non-interactive run");
|
|
12581
|
+
const ans = await prompt(`drop collection '${collection}'? type the collection name to confirm: `);
|
|
12582
|
+
if (ans !== collection) fail("confirmation did not match \u2014 aborted.");
|
|
12583
|
+
}
|
|
12584
|
+
await dropCollection(api2, collection);
|
|
12585
|
+
if (jsonOut) {
|
|
12586
|
+
console.log(JSON.stringify({ ok: true, collection, fields: plan.fields, rows: plan.rows, declared_locally: declaredLocally }));
|
|
12587
|
+
} else {
|
|
12588
|
+
console.log(`\u2713 dropped collection ${collection} (${plan.fields} field(s); ${plan.rows === null ? "stored rows" : `${plan.rows} stored row(s)`} retained, unreachable until re-created)`);
|
|
12589
|
+
}
|
|
12590
|
+
break;
|
|
12591
|
+
}
|
|
12348
12592
|
const { api } = requireApi(selector());
|
|
12349
12593
|
if (sub === "status") {
|
|
12350
12594
|
printEnvelope(await api("GET", "/v1/cms/collections"));
|
|
@@ -12365,7 +12609,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
|
|
|
12365
12609
|
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`);
|
|
12366
12610
|
}
|
|
12367
12611
|
} else {
|
|
12368
|
-
fail("usage: vxil cms status|pull|reindex <collection> [--field <field>]");
|
|
12612
|
+
fail("usage: vxil cms status|pull|reindex <collection> [--field <field>]|drop <collection> [--yes]");
|
|
12369
12613
|
}
|
|
12370
12614
|
break;
|
|
12371
12615
|
}
|
|
@@ -12441,28 +12685,24 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
|
|
|
12441
12685
|
break;
|
|
12442
12686
|
}
|
|
12443
12687
|
case "api": {
|
|
12444
|
-
|
|
12445
|
-
|
|
12446
|
-
|
|
12447
|
-
|
|
12448
|
-
|
|
12449
|
-
if (raw !== void 0) {
|
|
12450
|
-
try {
|
|
12451
|
-
body = JSON.parse(raw);
|
|
12452
|
-
} catch {
|
|
12453
|
-
fail("--data must be valid JSON");
|
|
12454
|
-
}
|
|
12688
|
+
let parsed;
|
|
12689
|
+
try {
|
|
12690
|
+
parsed = parseApiArgs(positional(0), flag("data"));
|
|
12691
|
+
} catch (e) {
|
|
12692
|
+
fail(e.message);
|
|
12455
12693
|
}
|
|
12456
|
-
|
|
12694
|
+
const { method, path, body } = parsed;
|
|
12695
|
+
const { api } = requireApi(selector(), method === "GET" ? {} : { write: `api ${method} ${path}` });
|
|
12696
|
+
printEnvelope(await api(method, path, body));
|
|
12457
12697
|
break;
|
|
12458
12698
|
}
|
|
12459
12699
|
default:
|
|
12460
12700
|
console.log(`vxil \u2014 a Supabase-grade code-first CLI for the whole platform
|
|
12461
|
-
init [--template <id>] \xB7 templates \xB7 try (keyless sandbox) \xB7 quickstart [--dev --ttl <h>] \xB7 login \xB7 link <slug> [--as dev|<name>]
|
|
12701
|
+
init [--template <id>] \xB7 templates \xB7 try (keyless sandbox) \xB7 quickstart [--dev --ttl <h>] \xB7 login \xB7 link <slug> [--key <k>] [--as dev|<name>] [--mint]
|
|
12462
12702
|
architect "<describe your app>" \u2014 plain English in, a reviewed vxil.config.ts draft out
|
|
12463
|
-
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>]
|
|
12703
|
+
plan [--explain] \xB7 push [--dev|--target <name>|--prod] [--server [--resume <apply_id>]] [--no-gen] [--skip-functions] \xB7 pull \xB7 gen [--check] [--offline] [--out <f>] [--no-mcp] [--mcp-out <f>]
|
|
12464
12704
|
push production overrides: --allow-mock-in-prod \xB7 --allow-sandbox-in-prod \xB7 --allow-dev-origins-in-prod \xB7 --yes
|
|
12465
|
-
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)
|
|
12705
|
+
diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--api-state] \u2014 CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error (an unreadable remote is 2; secrets by name only, unstored refs informational unless --strict-secrets)
|
|
12466
12706
|
doctor [--dev|--target <name>] \u2014 + secrets referenced-vs-stored (advisory) + per-function served/published/local hash
|
|
12467
12707
|
versions <feature> \xB7 rollback <feature> --to <v>
|
|
12468
12708
|
secrets set|list [--all-projects]|rm \xB7 seed \xB7 export|import <feature>
|
|
@@ -12471,11 +12711,11 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
|
|
|
12471
12711
|
migrate payments --from-provider stripe|paddle|paypal|revenuecat (--customers <f.csv> | --all) [--dry-run] \u2014 backfill subscriptions via the server sync leg (resumable)
|
|
12472
12712
|
payments simulate --scenario refund-pair|cross-platform-unlock|renewal|expiry|past-due-grace|transfer --user <id> [--tier <t>] \u2014 mock/dev projects only
|
|
12473
12713
|
env pull [--dev] [--file <f>] [--print] [--no-gitignore]
|
|
12474
|
-
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
|
|
12714
|
+
functions new|deploy|list|delete|invoke [--async]|logs [--tail]|dev <name> [--port <n>] \xB7 cms status|pull|reindex <collection>|drop <collection> [--yes] \xB7 dev up [--ttl <h>] [--new]|down [--yes]|seed|reset
|
|
12475
12715
|
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
|
|
12476
12716
|
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
|
|
12477
12717
|
dev branch [<name>] [--ttl <h=24>] [--no-env] [--print] | --list | --rm <name> [--yes]
|
|
12478
|
-
config get \xB7 users \xB7 send \xB7 deliveries \xB7 api <METHOD> <path>
|
|
12718
|
+
config get \xB7 users \xB7 send \xB7 deliveries \xB7 api <METHOD> <path> ['<json>' | --data '<json>']
|
|
12479
12719
|
global: --json (machine-readable + exit codes)`);
|
|
12480
12720
|
process.exit(cmd ? 1 : 0);
|
|
12481
12721
|
}
|
|
@@ -12985,7 +13225,7 @@ async function runFunctionsDev(name) {
|
|
|
12985
13225
|
// parity with the deploy pipeline's metadata
|
|
12986
13226
|
outboundService: (req) => outbound(req)
|
|
12987
13227
|
});
|
|
12988
|
-
const first = await bundleFunction(entry, { projectRoot: cwd });
|
|
13228
|
+
const first = await bundleFunction(entry, { projectRoot: cwd, minify: false });
|
|
12989
13229
|
const mf = new Miniflare(mfOptions(first.source));
|
|
12990
13230
|
const { secrets, missing } = resolveDevSecrets(def.secrets, process.env);
|
|
12991
13231
|
for (const m of missing) {
|
|
@@ -13044,7 +13284,7 @@ async function runFunctionsDev(name) {
|
|
|
13044
13284
|
timer2 = setTimeout(() => {
|
|
13045
13285
|
void (async () => {
|
|
13046
13286
|
try {
|
|
13047
|
-
const rebuilt = await bundleFunction(entry, { projectRoot: cwd });
|
|
13287
|
+
const rebuilt = await bundleFunction(entry, { projectRoot: cwd, minify: false });
|
|
13048
13288
|
await mf.setOptions(mfOptions(rebuilt.source));
|
|
13049
13289
|
console.log(` \u21BB rebuilt '${name}' (${Math.round(rebuilt.bytes / 100) / 10} KB) \u2014 hot-swapped`);
|
|
13050
13290
|
} catch (e) {
|