@vxil/cli 0.20.2 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/vxil.js CHANGED
@@ -3383,6 +3383,9 @@ var PublicAssetsConfigSchema = Type.Object({
3383
3383
  ))
3384
3384
  });
3385
3385
 
3386
+ // ../feature-configs/src/geminiThinking.ts
3387
+ var AI_THINKING_LEVELS = ["minimal", "low", "medium", "high"];
3388
+
3386
3389
  // ../feature-configs/src/canonicalJson.ts
3387
3390
  function canonicalJson(v) {
3388
3391
  if (Array.isArray(v)) return `[${v.map((x) => canonicalJson(x) ?? "null").join(",")}]`;
@@ -3963,8 +3966,8 @@ var FilesConfigSchema = Type.Object({
3963
3966
  // (pricing review 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute.
3964
3967
  maxObjectBytes: Type.Integer({ default: 100 * 1024 * 1024, minimum: 1, maximum: 5 * 1024 * 1024 * 1024 }),
3965
3968
  // NO default (2026-10-04): UNSET means "the plan's storage ceiling"
3966
- // (control-plane plans.ts FILES_MAX_TOTAL_BYTES_BY_TIER — Free/Developer
3967
- // 10 GiB, Team 50 GiB, Business 256 GiB, Enterprise 1 TiB), resolved by
3969
+ // (control-plane plans.ts FILES_MAX_TOTAL_BYTES_BY_TIER — Free 10 GiB,
3970
+ // Developer 25 GiB, Team 150 GiB, Business 256 GiB, Enterprise 1 TiB), resolved by
3968
3971
  // files-v1 where the quota is enforced, so a plan change applies at once.
3969
3972
  // A value is the project's own LOWER cap (above the plan → 422 at config
3970
3973
  // write; a downgrade clamps it). It used to default to 10 GiB on every
@@ -4469,11 +4472,18 @@ var AiConfigSchema = Type.Object({
4469
4472
  openaiBaseUrl: Type.Optional(Type.String({ maxLength: 512 }))
4470
4473
  }))
4471
4474
  }, { default: {} }),
4472
- defaults: Type.Object({
4475
+ // `defaults` became an OPTIONAL bag (3 leaves → 1, the `streaming` trick) on
4476
+ // 2026-10-10 to carry `thinkingLevel` (AI-THINKING-LEVEL). It KEEPS
4477
+ // `default: {}`, so Value.Default still materializes
4478
+ // defaults.{model,maxTokens,temperature} exactly as before; `thinkingLevel`
4479
+ // has NO default (absent = the model's own default), so an existing manifest
4480
+ // stays byte-identical. Sent to Gemini only; validated per model below.
4481
+ defaults: Type.Optional(Type.Object({
4473
4482
  model: Type.String({ default: "mock-1" }),
4474
4483
  maxTokens: Type.Integer({ default: 1024, minimum: 1, maximum: 2e5 }),
4475
- temperature: Type.Number({ default: 0.7, minimum: 0, maximum: 2 })
4476
- }, { default: {} }),
4484
+ temperature: Type.Number({ default: 0.7, minimum: 0, maximum: 2 }),
4485
+ thinkingLevel: Type.Optional(Type.Union(AI_THINKING_LEVELS.map((l) => Type.Literal(l))))
4486
+ }, { default: {} })),
4477
4487
  cache: Type.Object({ ttlSeconds: Type.Integer({ default: 0, minimum: 0 }) }, { default: {} }),
4478
4488
  // 0 = off
4479
4489
  limits: Type.Object({
@@ -4825,7 +4835,7 @@ var FunctionsConfigSchema = Type.Object({
4825
4835
  collection: Type.Optional(Type.String()),
4826
4836
  // cmsHook: the CMS collection slug
4827
4837
  event: Type.Optional(Type.String()),
4828
- // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
4838
+ // cmsHook: beforeCreate|beforeUpdate|beforeWrite|afterPublish · authHook: 'user.created'
4829
4839
  // (2026-09-25) the per-binding opt-in to re-delivery on
4830
4840
  // queue / webhook / cmsHook / authHook, and since 2026-10-03 on
4831
4841
  // cron and http (the async lane only). Absent = the ACK-200 default. The
@@ -4940,7 +4950,7 @@ var CopilotConfigSchema = Type.Object({
4940
4950
  directiveRef: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
4941
4951
  tone: Type.Optional(Type.String({ maxLength: 200 })),
4942
4952
  greeting: Type.Optional(Type.String({ maxLength: 500 })),
4943
- locale: Type.Optional(Type.String({ maxLength: 16, default: "en-US" })),
4953
+ locale: Type.Optional(Type.String({ maxLength: 16, default: "en-US", description: "Reserved: accepted but not read by the copilot yet." })),
4944
4954
  // ── knowledge: vector-search collection refs + retrieval policy ───────
4945
4955
  // Pipeline knobs (topK/mmr/budget/tokenizer) stay in RagConfig.
4946
4956
  // Interior fields are ALL Optional (Value.Default does not recurse into
@@ -4948,8 +4958,8 @@ var CopilotConfigSchema = Type.Object({
4948
4958
  // annotations document the read-time `?? default` the worker applies.
4949
4959
  knowledge: Type.Optional(Type.Object({
4950
4960
  collections: Type.Optional(Type.Array(Type.String({ maxLength: 128 }), { maxItems: 32, default: [] })),
4951
- useRag: Type.Optional(Type.Boolean({ default: true })),
4952
- ragTemplateRef: Type.Optional(Type.String({ maxLength: 128 })),
4961
+ useRag: Type.Optional(Type.Boolean({ default: true, description: "Reserved: accepted but not read by the copilot yet." })),
4962
+ ragTemplateRef: Type.Optional(Type.String({ maxLength: 128, description: "Reserved: accepted but not read by the copilot yet." })),
4953
4963
  topKOverride: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 }))
4954
4964
  })),
4955
4965
  // ── citations + grounding ─────────────────────────────────────────────
@@ -4969,7 +4979,7 @@ var CopilotConfigSchema = Type.Object({
4969
4979
  // mcp toolKey
4970
4980
  Type.Object({
4971
4981
  enabled: Type.Optional(Type.Boolean({ default: true })),
4972
- requireConfirm: Type.Optional(Type.Boolean({ default: true })),
4982
+ requireConfirm: Type.Optional(Type.Boolean({ default: true, description: "Recorded on the proposal as require_confirm, a hint to your app. Every write is still proposed and runs only on confirm." })),
4973
4983
  minScope: Type.Optional(Type.String({ maxLength: 64 }))
4974
4984
  }),
4975
4985
  { default: {} }
@@ -4981,7 +4991,7 @@ var CopilotConfigSchema = Type.Object({
4981
4991
  rateLimitPerUserPerDay: Type.Optional(Type.Integer({ default: 50, minimum: 0 })),
4982
4992
  // 0 = unlimited
4983
4993
  maxInputChars: Type.Optional(Type.Integer({ default: 4e3, minimum: 1, maximum: 32e3 })),
4984
- refusalMessage: Type.Optional(Type.String({ maxLength: 500 })),
4994
+ refusalMessage: Type.Optional(Type.String({ maxLength: 500, description: "The answer a strict-grounding agent gives when retrieval finds nothing." })),
4985
4995
  allowGuest: Type.Optional(Type.Boolean({ default: false })),
4986
4996
  guestToolAllow: Type.Optional(Type.Array(Type.String({ maxLength: 64 }), { maxItems: 16, default: [] }))
4987
4997
  }))
@@ -5065,14 +5075,16 @@ function lowerAllTriggerBindings(def, name) {
5065
5075
  return bindings;
5066
5076
  }
5067
5077
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
5068
- var VXIL_CONFIG_PKG_VERSION = "0.13.0";
5069
- var VXIL_SDK_PKG_VERSION = "0.20.1";
5070
- var VXIL_CLI_PKG_VERSION = "0.20.2";
5078
+ var VXIL_CONFIG_PKG_VERSION = "0.15.0";
5079
+ var VXIL_SDK_PKG_VERSION = "0.22.0";
5080
+ var VXIL_CLI_PKG_VERSION = "0.22.0";
5071
5081
  function ensureScaffoldPackageJson(cwd, opts = {}) {
5072
5082
  const file = resolve(cwd, "package.json");
5073
5083
  const wanted = {
5074
5084
  "@vxil/config": `^${VXIL_CONFIG_PKG_VERSION}`,
5075
- ...opts.sdkTypes ? { "@vxil/sdk": `^${VXIL_SDK_PKG_VERSION}` } : {}
5085
+ ...opts.sdkTypes ? { "@vxil/sdk": `^${VXIL_SDK_PKG_VERSION}` } : {},
5086
+ // the scaffolded tsconfig.json names `types: ["node"]` (CLI-TS-SCAFFOLD)
5087
+ ...opts.nodeTypes ? { "@types/node": SCAFFOLD_NODE_TYPES_RANGE } : {}
5076
5088
  };
5077
5089
  if (!existsSync(file)) {
5078
5090
  writeFileSync(file, JSON.stringify({
@@ -5102,6 +5114,24 @@ function ensureScaffoldPackageJson(cwd, opts = {}) {
5102
5114
  writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n");
5103
5115
  return { action: "added" };
5104
5116
  }
5117
+ var SCAFFOLD_NODE_TYPES_RANGE = "^24.0.0";
5118
+ var SCAFFOLD_TSCONFIG = {
5119
+ compilerOptions: {
5120
+ target: "ES2022",
5121
+ module: "ESNext",
5122
+ moduleResolution: "bundler",
5123
+ strict: true,
5124
+ skipLibCheck: true,
5125
+ noEmit: true,
5126
+ types: ["node"]
5127
+ }
5128
+ };
5129
+ function ensureScaffoldTsconfig(cwd) {
5130
+ const file = resolve(cwd, "tsconfig.json");
5131
+ if (existsSync(file)) return "exists";
5132
+ writeFileSync(file, JSON.stringify(SCAFFOLD_TSCONFIG, null, 2) + "\n");
5133
+ return "created";
5134
+ }
5105
5135
  function apiErrText(r) {
5106
5136
  const e = r.body.error;
5107
5137
  if (!e) return `${r.status}`;
@@ -5353,6 +5383,49 @@ async function serverApply({ api, bundle, resume, allowDestructive, onPlan }) {
5353
5383
  );
5354
5384
  }
5355
5385
  var PUSH_READ_CONCURRENCY = 5;
5386
+ var CMS_ENABLE_MANIFEST = { enabled: true };
5387
+ function cmsEnableManifest(declared) {
5388
+ return declared.limits !== void 0 ? { ...CMS_ENABLE_MANIFEST, limits: declared.limits } : { ...CMS_ENABLE_MANIFEST };
5389
+ }
5390
+ async function enableCmsBeforeSchema({ api, cfg }) {
5391
+ const declared = cfg.features?.cms;
5392
+ if (!Object.keys(cfg.cms?.collections ?? {}).length || !declared || declared.enabled === false) return null;
5393
+ const cur = await api("GET", "/v1/config/cms");
5394
+ if (classifyRead(cur.status, cur.body?.error?.code) !== "empty") return null;
5395
+ const dry = await api("PUT", "/v1/config/cms?dry_run=true", declared);
5396
+ if (dry.status !== 200) {
5397
+ const e = dry.body.error ?? {};
5398
+ throw new Error(`invalid config for cms: ${e.code ?? dry.status} ${e.message ?? ""} ${e.hint ?? ""}`.trim() + "\n(nothing was pushed)");
5399
+ }
5400
+ const put = await api("PUT", "/v1/config/cms", cmsEnableManifest(declared));
5401
+ if (put.status !== 200) {
5402
+ const e = put.body.error ?? {};
5403
+ throw new Error(`cms: could not enable cms before its schema: ${e.code ?? put.status} ${e.message ?? ""} ${e.hint ?? ""}`.trim());
5404
+ }
5405
+ return put.body.data?.version ?? 0;
5406
+ }
5407
+ var CMS_PROPAGATION_ATTEMPTS = 4;
5408
+ var CMS_PROPAGATION_DELAY_MS = 1e4;
5409
+ function retryWhileCmsPropagates(api, opts = {}) {
5410
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
5411
+ const attempts = opts.attempts ?? CMS_PROPAGATION_ATTEMPTS;
5412
+ const delayMs = opts.delayMs ?? CMS_PROPAGATION_DELAY_MS;
5413
+ let waited = 0;
5414
+ return async (method, path, body) => {
5415
+ for (; ; ) {
5416
+ const res = await api(method, path, body);
5417
+ const propagating = path.startsWith("/v1/cms/") && res.status === 501 && (res.body?.error?.code ?? "capability_not_enabled") === "capability_not_enabled";
5418
+ if (!propagating || waited >= attempts) return res;
5419
+ waited++;
5420
+ opts.onWait?.(` \u2026 cms config propagating (just enabled) \u2014 retrying in ${Math.round(delayMs / 1e3)} s (${waited}/${attempts})`);
5421
+ await sleep(delayMs);
5422
+ }
5423
+ };
5424
+ }
5425
+ function onlyPropagationDeferred(steps) {
5426
+ const errored = steps.filter((st) => st.status === "pending" && st.error);
5427
+ return errored.length > 0 && errored.every((st) => (st.kind === "api-state" || st.kind === "cms.collection" || st.kind === "cms.field") && (st.error ?? "").startsWith("deferred:"));
5428
+ }
5356
5429
  async function planOrPush({ api, features, apply, allowDestructive }) {
5357
5430
  const results = [];
5358
5431
  const destructiveQs = allowDestructive ? "&allow_destructive=true" : "";
@@ -7640,6 +7713,11 @@ var KNOWN_SCOPES = [
7640
7713
  "ai:write",
7641
7714
  "rag:read",
7642
7715
  "rag:write",
7716
+ // Corpus ingest: POST /v1/rag/ingest/:collection
7717
+ // accepts it alongside 'rag:write' (any-of) through a deprecation window, so
7718
+ // existing keys keep ingesting. Mint it on the server key that loads content,
7719
+ // and keep a browser key to rag:write (answer) only.
7720
+ "rag:ingest",
7643
7721
  "activity-feed:read",
7644
7722
  "activity-feed:write",
7645
7723
  "functions:invoke",
@@ -7787,8 +7865,8 @@ function isPositiveInt(v) {
7787
7865
  return typeof v === "number" && Number.isInteger(v) && v > 0;
7788
7866
  }
7789
7867
  var FUNCTIONS_RUNTIME = {
7790
- compatibilityDate: "2025-09-01",
7791
- compatibilityFlags: []
7868
+ compatibilityDate: "2026-07-30",
7869
+ compatibilityFlags: ["do_not_strip_bom_in_read_all_text"]
7792
7870
  };
7793
7871
  var FUNCTIONS_RUNTIME_LEGACY = {
7794
7872
  compatibilityDate: "2025-09-01",
@@ -8594,7 +8672,7 @@ async function readRemote(api) {
8594
8672
  const map2 = /* @__PURE__ */ new Map();
8595
8673
  const unavailable = readUnavailable("GET /v1/functions", res);
8596
8674
  const wire = res.status === 200 ? res.body.data?.runtime : void 0;
8597
- const deployRuntime = wire ? recordedFunctionsRuntime(wire) : FUNCTIONS_RUNTIME;
8675
+ const deployRuntime = wire ? recordedFunctionsRuntime(wire) : FUNCTIONS_RUNTIME_LEGACY;
8598
8676
  const ceilingWire = res.status === 200 ? res.body.data?.cpuMsCeiling : void 0;
8599
8677
  const cpuMsCeiling = typeof ceilingWire === "number" && Number.isInteger(ceilingWire) && ceilingWire > 0 ? ceilingWire : null;
8600
8678
  if (res.status !== 200) return { map: map2, unavailable, deployRuntime, cpuMsCeiling };
@@ -8912,23 +8990,38 @@ async function liveRowCount(api, collection) {
8912
8990
  return null;
8913
8991
  }
8914
8992
  }
8993
+ function remoteCollectionOf(c) {
8994
+ return {
8995
+ collection: c.collection,
8996
+ ownerField: c.owner_field ?? null,
8997
+ public: c.public === true,
8998
+ endUserAccess: endUserAccessOf(c.end_user_access),
8999
+ writeRoles: writeRolesOf(c.write_roles),
9000
+ actions: normalizeActions(c.actions),
9001
+ fields: Array.isArray(c.fields) ? c.fields : []
9002
+ };
9003
+ }
9004
+ function pulledCollectionBlock(c) {
9005
+ const rc = remoteCollectionOf(c);
9006
+ const fields = {};
9007
+ for (const fd of rc.fields) fields[fd.field] = remoteFieldToConfig(fd);
9008
+ return {
9009
+ ...c.singular ? { singular: c.singular } : {},
9010
+ ...rc.ownerField ? { ownerField: rc.ownerField } : {},
9011
+ ...rc.public ? { public: true } : {},
9012
+ ...rc.endUserAccess !== "readwrite" ? { endUserAccess: rc.endUserAccess } : {},
9013
+ ...rc.writeRoles.length ? { writeRoles: rc.writeRoles } : {},
9014
+ ...rc.actions.length ? { actions: rc.actions } : {},
9015
+ fields
9016
+ };
9017
+ }
8915
9018
  async function readRemote2(api) {
8916
9019
  const res = await api("GET", "/v1/cms/collections");
8917
9020
  const map2 = /* @__PURE__ */ new Map();
8918
9021
  const unavailable = readUnavailable("GET /v1/cms/collections", res);
8919
9022
  if (res.status !== 200) return { map: map2, unavailable };
8920
9023
  const cols = Array.isArray(res.body.data?.collections) ? res.body.data.collections : [];
8921
- for (const c of cols) {
8922
- map2.set(c.collection, {
8923
- collection: c.collection,
8924
- ownerField: c.owner_field ?? null,
8925
- public: c.public === true,
8926
- endUserAccess: endUserAccessOf(c.end_user_access),
8927
- writeRoles: writeRolesOf(c.write_roles),
8928
- actions: normalizeActions(c.actions),
8929
- fields: Array.isArray(c.fields) ? c.fields : []
8930
- });
8931
- }
9024
+ for (const c of cols) map2.set(c.collection, remoteCollectionOf(c));
8932
9025
  return { map: map2, unavailable: null };
8933
9026
  }
8934
9027
  var REINDEX_PAGE_CAP = 1e4;
@@ -9262,7 +9355,11 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
9262
9355
  applied++;
9263
9356
  } else if (c.kind === "set-owner-field") {
9264
9357
  const target = collections[c.collection].ownerField ?? null;
9265
- const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { owner_field: target });
9358
+ const res = await api(
9359
+ "POST",
9360
+ `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`,
9361
+ target === null ? { owner_field: null, allow_destructive: true } : { owner_field: target }
9362
+ );
9266
9363
  if (res.status !== 200 && res.status !== 201) {
9267
9364
  const e = res.body.error ?? {};
9268
9365
  throw new Error(`cms: set owner_field on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
@@ -9381,7 +9478,8 @@ function formatCollectionDrop(collection, plan, declaredLocally) {
9381
9478
  ` ! 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)`
9382
9479
  ];
9383
9480
  if (declaredLocally) {
9384
- 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`);
9481
+ const back = plan.rows === null ? "the retained rows" : `the ${plan.rows} retained row(s)`;
9482
+ lines.push(` \u26A0 '${collection}' is still declared in vxil.config.ts \u2014 the next \`vxil push\` re-creates it and ${back} come back with it; remove it from the config too`);
9385
9483
  }
9386
9484
  return lines.join("\n");
9387
9485
  }
@@ -9561,7 +9659,7 @@ var API_CHEATSHEET = [
9561
9659
  { feature: "orgs", what: "add / remove a member", cmd: `vxil api POST /v1/orgs/<org_id>/members '{"user_id":"<id>","role":"member"}' \xB7 vxil api DELETE /v1/orgs/<org_id>/members/<user_id>` },
9562
9660
  { feature: "orgs", what: "invite by e-mail (the token is returned once) / revoke", cmd: `vxil api POST /v1/orgs/<org_id>/invitations '{"email":"a@example.com","role":"member"}' \xB7 vxil api DELETE /v1/orgs/invitations/<invite_id>` },
9563
9661
  // feeds
9564
- { feature: "activity-feed", what: "block or mute a feed for a user", cmd: `vxil api POST /v1/feeds/blocks '{"owner":"<user>","blocked":"<user>","mode":"block"}'` },
9662
+ { feature: "activity-feed", what: "block or mute a feed for a user", cmd: `vxil api POST /v1/feeds/blocks '{"owner":"timeline:<reader_id>","blocked":"user:<author_id>","mode":"block"}'` },
9565
9663
  { feature: "activity-feed", what: "remove an activity", cmd: "vxil api DELETE /v1/feeds/<group>/<feed_id>/activities/<ref>" }
9566
9664
  ];
9567
9665
  function apiCheatSheet() {
@@ -9967,6 +10065,7 @@ function envLabelWarning(label, baseUrl) {
9967
10065
  function quickstartEnvNotice(envFlag, baseUrl, slug, opts = {}) {
9968
10066
  if (envFlag !== void 0) return envLabelWarning(envFlag, baseUrl);
9969
10067
  if (envLabelFor(baseUrl) !== "production") return null;
10068
+ if (opts.label !== void 0 && isNonProductionLabel(opts.label)) return null;
9970
10069
  const label = opts.dev ? "dev" : "staging";
9971
10070
  return `bound '${slug}' as 'production' \u2014 the host is ${hostOf(baseUrl)} and no --env was given, so \`vxil push\` runs the production promotion gate for it (mock/sandbox refusals, the typed confirmation). If this is a staging or dev project, relabel it now with \`vxil link ${slug} --env ${label}\` (it reuses the stored key), or next time run \`vxil quickstart --env ${label}\`.`;
9972
10071
  }
@@ -9988,11 +10087,23 @@ function leafAt(manifest, dotted) {
9988
10087
  }
9989
10088
  return cur;
9990
10089
  }
10090
+ var MOCK_DEFAULT_LEAVES = [
10091
+ { feature: "payments", path: "provider" },
10092
+ { feature: "ai", path: "defaultProvider" },
10093
+ { feature: "vector-search", path: "embed.provider" },
10094
+ { feature: "files", path: "extractText.provider", when: "extractText.enabled" }
10095
+ ];
9991
10096
  function mockFeatures(cfg) {
9992
- return Object.entries(cfg.features ?? {}).filter(([, conf]) => {
10097
+ return Object.entries(cfg.features ?? {}).filter(([feature, conf]) => {
9993
10098
  if (!isPlainObject(conf)) return false;
9994
10099
  const c = conf;
9995
- return c.provider === "mock" || c.defaultProvider === "mock" || (isPlainObject(c.embed) ? c.embed.provider === "mock" : false);
10100
+ if (c.provider === "mock" || c.defaultProvider === "mock" || (isPlainObject(c.embed) ? c.embed.provider === "mock" : false)) return true;
10101
+ return MOCK_DEFAULT_LEAVES.some((leaf) => {
10102
+ if (leaf.feature !== feature) return false;
10103
+ if (leaf.when !== void 0 && leafAt(c, leaf.when) !== true) return false;
10104
+ const v = leafAt(c, leaf.path);
10105
+ return v === "mock" || v === void 0 && c.enabled !== false;
10106
+ });
9996
10107
  }).map(([f]) => f);
9997
10108
  }
9998
10109
  var SANDBOX_LEAVES = [
@@ -10165,6 +10276,12 @@ function everySlot(p) {
10165
10276
  for (const [n, s] of Object.entries(p.branches ?? {})) out.push({ id: `branch:${n}`, where: `branch '${n}'`, slug: s.slug, tenant_id: s.tenant_id });
10166
10277
  return out;
10167
10278
  }
10279
+ function keptEnvLabel(existing, slug, tenantId, as) {
10280
+ if (!existing || !knownTenant(tenantId)) return void 0;
10281
+ if (as === void 0) return existing.slug === slug && existing.tenant_id === tenantId ? existing.env : void 0;
10282
+ const t = existing.targets?.[as];
10283
+ return t && t.slug === slug && t.tenant_id === tenantId ? t.env : void 0;
10284
+ }
10168
10285
  function planLinkBinding(existing, req) {
10169
10286
  const { slug, tenantId, edgeUrl, env, as } = req;
10170
10287
  if (as !== void 0 && !TARGET_NAME_RE.test(as)) {
@@ -10686,6 +10803,41 @@ var PROJECT_WORKLOADS = ["production", "staging", "development"];
10686
10803
  function isWorkload(v) {
10687
10804
  return typeof v === "string" && PROJECT_WORKLOADS.includes(v);
10688
10805
  }
10806
+ function workloadEnvLabel(w) {
10807
+ return w === "production" ? "production" : w === "staging" ? "staging" : "dev";
10808
+ }
10809
+ function quickstartWorkload(workloadFlag, envFlag, dev) {
10810
+ if (workloadFlag !== void 0) {
10811
+ if (!isWorkload(workloadFlag)) return { error: `--workload must be one of ${PROJECT_WORKLOADS.join(", ")}` };
10812
+ if (dev && workloadFlag !== "development") return { error: "a --dev tenant's workload is always 'development'" };
10813
+ return { workload: workloadFlag };
10814
+ }
10815
+ if (dev) return { workload: "development" };
10816
+ if (envFlag !== void 0) {
10817
+ const n = envFlag.trim().toLowerCase();
10818
+ if (n === "dev" || n === "development" || n === "local") return { workload: "development" };
10819
+ return { workload: isNonProductionLabel(n) ? "staging" : "production" };
10820
+ }
10821
+ return { workload: "staging" };
10822
+ }
10823
+ function relabelBindingForWorkload(binding, tenantId, w) {
10824
+ if (!binding) return null;
10825
+ const label = workloadEnvLabel(w);
10826
+ const relabeled = [];
10827
+ let next = binding;
10828
+ if (binding.tenant_id === tenantId && binding.env !== label) {
10829
+ next = { ...next, env: label };
10830
+ relabeled.push("primary");
10831
+ }
10832
+ const targets = binding.targets ?? {};
10833
+ for (const [name, t] of Object.entries(targets)) {
10834
+ if (t.tenant_id === tenantId && t.env !== label) {
10835
+ next = { ...next, targets: { ...next.targets ?? {}, [name]: { ...t, env: label } } };
10836
+ relabeled.push(`target '${name}'`);
10837
+ }
10838
+ }
10839
+ return relabeled.length ? { binding: next, relabeled } : null;
10840
+ }
10689
10841
  async function listProjectsViaSession(dash, cookie) {
10690
10842
  const r = await dash("GET", "/dashboard/tenants", void 0, cookie);
10691
10843
  if (r.status === 401) return { unauthorized: true };
@@ -11212,6 +11364,9 @@ async function importUsers(api, users, opts = {}) {
11212
11364
  return { upserted, chunks };
11213
11365
  }
11214
11366
 
11367
+ // src/fnDev.ts
11368
+ import { createRequire } from "node:module";
11369
+
11215
11370
  // src/fnLanes.ts
11216
11371
  var LOCAL_LANES = ["http", "webhook", "cms-hook", "auth-hook", "queue", "cron"];
11217
11372
  function parseLane(raw) {
@@ -11241,7 +11396,8 @@ function resolveTriggerKind(inner, bindings) {
11241
11396
  var CMS_HOOK_EVENTS_FOR = {
11242
11397
  beforeCreate: ["cms.item.created"],
11243
11398
  beforeUpdate: ["cms.item.updated"],
11244
- beforeWrite: ["cms.item.created", "cms.item.updated"]
11399
+ beforeWrite: ["cms.item.created", "cms.item.updated"],
11400
+ afterPublish: ["cms.item.published"]
11245
11401
  };
11246
11402
  function cmsHookBindingMatches(bindings, event, collection) {
11247
11403
  const hooks = (bindings ?? []).filter((b2) => b2.kind === "cmsHook" || b2.kind === "cms-hook");
@@ -11482,6 +11638,20 @@ function liveWritesBanner(o) {
11482
11638
  }
11483
11639
 
11484
11640
  // src/fnDev.ts
11641
+ function installedLocalRuntimeDate() {
11642
+ try {
11643
+ const fromCli = createRequire(import.meta.url);
11644
+ const workerd = createRequire(fromCli.resolve("miniflare"))("workerd");
11645
+ return typeof workerd.compatibilityDate === "string" ? workerd.compatibilityDate : void 0;
11646
+ } catch {
11647
+ return void 0;
11648
+ }
11649
+ }
11650
+ function localRuntimeTooOld(installed, required) {
11651
+ if (installed === void 0 || installed >= required) return null;
11652
+ const floor = `^4.${required.replaceAll("-", "")}.0`;
11653
+ return `the installed miniflare runs a local runtime from ${installed}, older than the functions runtime (${required}) \u2014 upgrade miniflare to ${floor} or newer (\`npm i -D @vxil/cli@latest miniflare@latest\`) and re-run.`;
11654
+ }
11485
11655
  var DEV_VXIL_BASE = "https://api.vxil.org";
11486
11656
  var PLACEHOLDER_PREFIX = "vxil-dev-placeholder.";
11487
11657
  var MINTABLE_AUDS = new Set(Object.values(FUNCTION_SCOPE_AUDIENCE));
@@ -11642,6 +11812,169 @@ function makeDevOutbound(opts) {
11642
11812
  };
11643
11813
  }
11644
11814
 
11815
+ // src/importNdjson.ts
11816
+ var IMPORT_PIECE_MAX_BYTES = 9 * 1024 * 1024;
11817
+ var IMPORT_PIECE_MAX_ROWS = 1e4;
11818
+ var isRecord3 = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
11819
+ var isHeader = (v) => isRecord3(v) && "vxil_bundle" in v && !("users" in v) && !("tables" in v);
11820
+ function isNdjsonExport(text) {
11821
+ const first = text.slice(0, text.indexOf("\n") === -1 ? text.length : text.indexOf("\n")).trim();
11822
+ try {
11823
+ return isHeader(JSON.parse(first));
11824
+ } catch {
11825
+ return false;
11826
+ }
11827
+ }
11828
+ function parseNdjsonExport(text) {
11829
+ const lines = text.split("\n");
11830
+ let header = null;
11831
+ const users = [];
11832
+ const tables = /* @__PURE__ */ new Map();
11833
+ const truncated = [];
11834
+ for (let i = 0; i < lines.length; i++) {
11835
+ const raw = lines[i].trim();
11836
+ if (!raw) continue;
11837
+ let v;
11838
+ try {
11839
+ v = JSON.parse(raw);
11840
+ } catch {
11841
+ throw new Error(`line ${i + 1} is not JSON`);
11842
+ }
11843
+ if (!header) {
11844
+ if (!isHeader(v)) throw new Error('the first line is not a vxil export header ({"vxil_bundle":\u2026}) \u2014 is this a `vxil export <feature> --ndjson` file?');
11845
+ header = v;
11846
+ continue;
11847
+ }
11848
+ if (isRecord3(v) && Array.isArray(v.truncated) && !("t" in v)) {
11849
+ truncated.push(...v.truncated);
11850
+ continue;
11851
+ }
11852
+ if (!isRecord3(v) || typeof v.t !== "string" || !isRecord3(v.r)) throw new Error(`line ${i + 1} is not an export row ({"t":<table>,"r":{\u2026}})`);
11853
+ if (v.t === "users") {
11854
+ users.push(v.r);
11855
+ continue;
11856
+ }
11857
+ let tb = tables.get(v.t);
11858
+ if (!tb) {
11859
+ tb = { name: v.t, user_ref: typeof v.user_ref === "string" ? v.user_ref : null, columns: [], rows: [], colSet: /* @__PURE__ */ new Set() };
11860
+ tables.set(v.t, tb);
11861
+ }
11862
+ for (const c of Object.keys(v.r)) if (!tb.colSet.has(c)) {
11863
+ tb.colSet.add(c);
11864
+ tb.columns.push(c);
11865
+ }
11866
+ tb.rows.push(v.r);
11867
+ }
11868
+ if (!header) throw new Error("the file is empty \u2014 no vxil export header");
11869
+ return { header, users, tables: [...tables.values()].map((t) => ({ name: t.name, user_ref: t.user_ref, columns: t.columns, rows: t.rows })), truncated };
11870
+ }
11871
+ function ndjsonImportPieces(exp, opts = {}) {
11872
+ const maxRows = opts.maxRows ?? IMPORT_PIECE_MAX_ROWS;
11873
+ const maxBytes = opts.maxBytes ?? IMPORT_PIECE_MAX_BYTES;
11874
+ const pieces = [];
11875
+ let cur = null;
11876
+ let rows = 0;
11877
+ let bytes = 0;
11878
+ const fresh = () => ({
11879
+ ...exp.header,
11880
+ users: [],
11881
+ tables: [],
11882
+ ...exp.truncated.length ? { truncated: exp.truncated } : {}
11883
+ });
11884
+ const place = (r) => {
11885
+ const size2 = Buffer.byteLength(JSON.stringify(r), "utf8") + 1;
11886
+ if (size2 > maxBytes) throw new Error(`one row is larger than the ${Math.round(maxBytes / 1024 / 1024)} MiB import piece`);
11887
+ if (!cur || rows + 1 > maxRows || bytes + size2 > maxBytes) {
11888
+ cur = fresh();
11889
+ pieces.push(cur);
11890
+ rows = 0;
11891
+ bytes = 0;
11892
+ }
11893
+ rows += 1;
11894
+ bytes += size2;
11895
+ return cur;
11896
+ };
11897
+ for (const u of exp.users) place(u).users.push(u);
11898
+ for (const t of exp.tables) {
11899
+ for (const r of t.rows) {
11900
+ const p = place(r);
11901
+ let into = p.tables.at(-1);
11902
+ if (!into || into.name !== t.name) {
11903
+ into = { name: t.name, user_ref: t.user_ref, columns: t.columns, rows: [] };
11904
+ p.tables.push(into);
11905
+ }
11906
+ into.rows.push(r);
11907
+ }
11908
+ }
11909
+ return pieces.length ? pieces : [fresh()];
11910
+ }
11911
+ function crossProjectSplitRefusal(pieces, targetTenantId) {
11912
+ if (pieces.length <= 1) return null;
11913
+ const src = pieces[0].source_tenant;
11914
+ if (typeof src === "string" && targetTenantId && src === targetTenantId) return null;
11915
+ const which = typeof src !== "string" ? "the file names no source project" : !targetTenantId ? "the target project is unknown (a bare key)" : "the file comes from another project";
11916
+ return `this import needs ${pieces.length} pieces and ${which}: in a restore into another project, record ids are re-keyed per piece, so a link between rows in different pieces keeps the source id (a dangling reference, or a piece that fails on every re-run). Import into the project the export came from, or pass --allow-split-links to accept that`;
11917
+ }
11918
+ async function importNdjson(api, feature, pieces, mode, opts = {}) {
11919
+ const total = pieces.length;
11920
+ let pending = pieces.map((bundle, i) => ({ i, bundle }));
11921
+ let imported = 0;
11922
+ let usersUpserted = 0;
11923
+ let erasedSkipped = 0;
11924
+ const tables = /* @__PURE__ */ new Map();
11925
+ const extras = /* @__PURE__ */ new Map();
11926
+ const failAt = (res, i) => {
11927
+ const e = isRecord3(res.body.error) ? res.body.error : { code: `http_${res.status}` };
11928
+ const next = opts.crossProject && e.code === "import_failed" ? "a row likely links to a row in another piece, which a restore into another project cannot resolve" : "merge is idempotent \u2014 re-run the same command to finish";
11929
+ const where = `piece ${i + 1} of ${total} (${imported} imported; ${next})`;
11930
+ return { ...res, body: { ...res.body, error: { ...e, message: `${typeof e.message === "string" ? e.message : "import failed"} \u2014 ${where}` } } };
11931
+ };
11932
+ while (pending.length) {
11933
+ const deferred = [];
11934
+ for (const p of pending) {
11935
+ const res = await api("POST", `/v1/import/${feature}`, { bundle: p.bundle, mode });
11936
+ if (res.status !== 200) {
11937
+ const code = isRecord3(res.body.error) ? res.body.error.code : void 0;
11938
+ if (code !== "import_failed") return failAt(res, p.i);
11939
+ deferred.push({ ...p, res });
11940
+ continue;
11941
+ }
11942
+ imported += 1;
11943
+ const d = isRecord3(res.body.data) ? res.body.data : {};
11944
+ usersUpserted += typeof d.users_upserted === "number" ? d.users_upserted : 0;
11945
+ for (const t of Array.isArray(d.tables) ? d.tables : []) {
11946
+ const acc = tables.get(t.table) ?? { table: t.table };
11947
+ for (const [k, v] of Object.entries(t)) {
11948
+ if (typeof v === "number") acc[k] = (acc[k] ?? 0) + v;
11949
+ else if (k !== "table") acc[k] = v;
11950
+ }
11951
+ tables.set(t.table, acc);
11952
+ }
11953
+ for (const k of ["warnings", "ignored_tables", "replace_retained", "truncated_tables"]) {
11954
+ if (Array.isArray(d[k])) for (const s of d[k]) {
11955
+ const set = extras.get(k) ?? /* @__PURE__ */ new Set();
11956
+ set.add(String(s));
11957
+ extras.set(k, set);
11958
+ }
11959
+ }
11960
+ if (typeof d.erased_skipped === "number") erasedSkipped += d.erased_skipped;
11961
+ }
11962
+ if (!deferred.length) break;
11963
+ if (deferred.length === pending.length) return failAt(deferred[0].res, deferred[0].i);
11964
+ pending = deferred.map(({ i, bundle }) => ({ i, bundle }));
11965
+ }
11966
+ const data2 = {
11967
+ feature,
11968
+ mode,
11969
+ pieces: total,
11970
+ users_upserted: usersUpserted,
11971
+ tables: [...tables.values()]
11972
+ };
11973
+ for (const [k, set] of extras) data2[k] = [...set];
11974
+ if (erasedSkipped > 0) data2.erased_skipped = erasedSkipped;
11975
+ return { status: 200, body: { data: data2 } };
11976
+ }
11977
+
11645
11978
  // src/gen.ts
11646
11979
  function tsTypeFor(fieldType) {
11647
11980
  switch (fieldType) {
@@ -11876,7 +12209,9 @@ function genInputFromConfig(cfg) {
11876
12209
  }
11877
12210
  function typesCheckBody(src) {
11878
12211
  const i = src.indexOf("export interface VxilSchema");
11879
- return i >= 0 ? src.slice(i) : src;
12212
+ const body = i >= 0 ? src.slice(i) : src;
12213
+ const usage = body.indexOf("\n// Usage \u2014 ");
12214
+ return usage >= 0 ? body.slice(0, usage) : body;
11880
12215
  }
11881
12216
  function checkGeneratedTypes(existing, generated, opts) {
11882
12217
  let have = typesCheckBody(withCurrentApiVersionsHead(existing));
@@ -11947,7 +12282,7 @@ export type VxilFilterJson = string | number | boolean | { $eq?: string | number
11947
12282
  // Usage \u2014 narrow the client to THIS tenant's enabled features (a disabled feature
11948
12283
  // becomes a compile error):
11949
12284
  // import { Vxil } from '@vxil/sdk';
11950
- // import type { VxilSchema } from './vxil.types';
12285
+ // import type { VxilSchema } from './vxil.types.js';
11951
12286
  // const vx = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! });
11952
12287
  // await vx.from('${colls[0]?.collection ?? "collection"}').create({ /* field-typed */ });
11953
12288
  `;
@@ -12939,7 +13274,7 @@ var TOOLS = [
12939
13274
  {
12940
13275
  name: "cms_aggregate_items",
12941
13276
  feature: "cms",
12942
- description: 'Bounded group-by aggregate over a collection (closed fn set: count sum min max avg \u2014 sum/avg need an index-slotted numeric field). groupBy: \u22642 slot-indexed fields. filter: the query DSL plus ONE-hop dotted join terms ({"channel.visibility":"public"} \u2014 the relation field must be slot-indexed). window: {field, sinceDays\u2264366}. Scan is capped at 50000 rows \u2014 a wider match returns 422 window_too_large (narrow it, or declare a materialized read-model). Returns { groups: [{key, <aliases>}], scanned }.',
13277
+ description: 'Bounded group-by aggregate over a collection (closed fn set: count sum min max avg \u2014 sum/avg need an index-slotted numeric field). groupBy: \u22642 slot-indexed fields. filter: the query DSL plus ONE-hop dotted join terms ({"channel.visibility":"public"} \u2014 the relation field must be slot-indexed). window: {field, sinceDays\u2264366}. Scan is capped at 50000 rows \u2014 a wider match returns 422 window_too_large (narrow it, or declare a materialized read-model). Returns { groups: [{key, <aliases>}], scanned, truncated, limit } \u2014 truncated:true means more groups matched than limit (default 100, max 500): raise limit or narrow the filter.',
12943
13278
  inputSchema: {
12944
13279
  type: "object",
12945
13280
  properties: {
@@ -13156,13 +13491,13 @@ var TOOLS = [
13156
13491
  {
13157
13492
  name: "orgs_add_member",
13158
13493
  feature: "orgs",
13159
- description: "Add a member to an org (or change their role): owner|admin|member|viewer.",
13494
+ description: "Add a member to an org (or change their role): a built-in owner|admin|member|viewer or a custom role_key from orgs_define_role.",
13160
13495
  inputSchema: {
13161
13496
  type: "object",
13162
13497
  properties: {
13163
13498
  org_id: { type: "string" },
13164
13499
  user_id: { type: "string" },
13165
- role: { type: "string", enum: ["owner", "admin", "member", "viewer"] }
13500
+ role: { type: "string", pattern: "^[a-z0-9][a-z0-9_-]{0,62}$" }
13166
13501
  },
13167
13502
  required: ["org_id", "user_id", "role"]
13168
13503
  },
@@ -13293,7 +13628,7 @@ var TOOLS = [
13293
13628
  {
13294
13629
  name: "webhooks_subscribe",
13295
13630
  feature: "webhooks",
13296
- description: "Subscribe an https endpoint to this tenant\u2019s event stream (audit events like user.upserted, job.succeeded). event_prefixes filters by name prefix; empty = all. Deliveries are signed jobs callbacks (verify X-Vxil-Jobs-Signature via jobs_get_signing_secret). Requires the jobs feature.",
13631
+ description: "Subscribe an https endpoint to this tenant\u2019s event stream (audit events like user.upserted, job.succeeded). event_prefixes filters by name prefix; empty = all. Deliveries are signed jobs callbacks (verify X-Vxil-Jobs-Signature via jobs_get_signing_secret).",
13297
13632
  inputSchema: {
13298
13633
  type: "object",
13299
13634
  properties: {
@@ -13552,7 +13887,7 @@ var TOOLS = [
13552
13887
  {
13553
13888
  name: "search_query",
13554
13889
  feature: "vector-search",
13555
- description: "Retrieve the top-k most relevant chunks from a vector-search collection. mode 'hybrid' (default) fuses semantic + keyword via RRF; 'vector' is pure semantic; 'keyword' is lexical. filter is a JSON object of equality constraints over chunk metadata. Returns ranked results with document/chunk attribution: score is the fused rank value (orders the hits, not a similarity); similarity is the cosine similarity to the query (-1..1, null in keyword mode) \u2014 threshold on similarity.",
13890
+ description: "Retrieve the top-k most relevant chunks from a vector-search collection. mode 'hybrid' (default) fuses semantic + keyword via RRF; 'vector' is pure semantic; 'keyword' is lexical. filter is a JSON object of equality constraints over chunk metadata. Returns ranked results with document/chunk attribution: score is the fused rank value (orders the hits, not a similarity); similarity is the cosine similarity to the query (-1..1, null in keyword mode) \u2014 threshold on similarity. embed_provider names the collection's embedder; 'mock' is the deterministic test embedder (ranks by a hash, not meaning), so tell the user search is not semantic until a real provider is set.",
13556
13891
  inputSchema: {
13557
13892
  type: "object",
13558
13893
  properties: {
@@ -13582,6 +13917,11 @@ var TOOLS = [
13582
13917
  model: { type: "string" },
13583
13918
  max_tokens: { type: "number", minimum: 1, maximum: 2e5 },
13584
13919
  temperature: { type: "number", minimum: 0, maximum: 2 },
13920
+ thinking_level: {
13921
+ type: "string",
13922
+ enum: ["minimal", "low", "medium", "high"],
13923
+ description: "Gemini thinking depth (ignored by other providers). Omitted: the tenant's defaults.thinkingLevel, then the model default. 'minimal' on a model that rejects it is a 422 thinking_level_unsupported. Thinking tokens bill as output."
13924
+ },
13585
13925
  json_mode: { type: "boolean", description: "Constrain the output to JSON (no schema \u2014 use response_schema for a guaranteed shape)." },
13586
13926
  response_schema: {
13587
13927
  type: "object",
@@ -13712,7 +14052,7 @@ var TOOLS = [
13712
14052
  {
13713
14053
  name: "rag_answer",
13714
14054
  feature: "rag",
13715
- description: "Answer a question grounded in a vector-search collection: retrieve \u2192 budget context \u2192 generate \u2192 cite. Defaults the collection/template/top_k/mode from the rag config; override per call. Returns the answer plus citations to the source chunks that grounded it.",
14055
+ description: "Answer a question grounded in a vector-search collection: retrieve \u2192 budget context \u2192 generate \u2192 cite. Defaults the collection/template/top_k/mode from the rag config; override per call. Returns the answer plus citations to the source chunks that grounded it, and embed_provider (the collection's embedder; 'mock' = the deterministic test embedder, so retrieval is not semantic yet). The collection must exist first (POST /v1/search/collections, SDK vx.search.createCollection); a missing one fails retrieval with upstream collection_not_found.",
13716
14056
  inputSchema: {
13717
14057
  type: "object",
13718
14058
  properties: {
@@ -13736,7 +14076,7 @@ var TOOLS = [
13736
14076
  {
13737
14077
  name: "rag_search",
13738
14078
  feature: "rag",
13739
- description: "Retrieval-only grounding preview: the exact chunks rag_answer would ground on, with rerank + metadata boosts applied \u2014 no generation, no token spend. Use it to inspect/tune retrieval (boost weights, rerank, min_similarity) before paying for an answer.",
14079
+ description: "Retrieval-only grounding preview: the exact chunks rag_answer would ground on, with rerank + metadata boosts applied \u2014 no generation, no token spend. Use it to inspect/tune retrieval (boost weights, rerank, min_similarity) before paying for an answer. embed_provider names the collection's embedder (mock = the deterministic test embedder, not semantic).",
13740
14080
  inputSchema: {
13741
14081
  type: "object",
13742
14082
  properties: {
@@ -13790,7 +14130,7 @@ var TOOLS = [
13790
14130
  {
13791
14131
  name: "payments_consume",
13792
14132
  feature: "payments",
13793
- description: "Debit credits from an end-user \u2014 idempotent (idempotency_key REQUIRED; the SAME key always returns the SAME result, never double-charges) and balance-guarded (never oversells; returns 402 INSUFFICIENT_CREDITS when available can't cover amount). Name ONE credit_type, or an ordered credit_types list (e.g. ['free','subscription','topup']): the WHOLE amount comes from the FIRST type whose available balance covers it \u2014 never split across types \u2014 and the answer's credit_type names the one debited. Pass job_id to make the debit a PROVISIONAL hold tied to a background job: a linked jobs run that terminally fails auto-refunds the hold, a success settles it. amount must be >= 1. Needs payments:write.",
14133
+ description: "Debit credits from an end-user \u2014 idempotent (idempotency_key REQUIRED; the SAME key always returns the SAME result, never double-charges) and balance-guarded (never oversells; returns 402 insufficient_credits when available can't cover amount). Name ONE credit_type, or an ordered credit_types list (e.g. ['free','subscription','topup']): the WHOLE amount comes from the FIRST type whose available balance covers it \u2014 never split across types \u2014 and the answer's credit_type names the one debited. Pass job_id to make the debit a PROVISIONAL hold tied to a background job: a linked jobs run that terminally fails auto-refunds the hold, a success settles it. amount must be >= 1. Needs payments:write.",
13794
14134
  inputSchema: {
13795
14135
  type: "object",
13796
14136
  properties: {
@@ -13882,7 +14222,7 @@ var TOOLS = [
13882
14222
  // Substrate tool (no `feature` — always-on like users_upsert): usage is a
13883
14223
  // platform property, not a feature you enable.
13884
14224
  name: "usage_current",
13885
- description: "Current-month usage for this project: requests used, the plan's included quota, remaining (null when the plan has no fixed cap), month-to-date per-feature metered detail (may lag \u2014 it is aggregated periodically), and `routes` \u2014 the same request count split by the surface that served it (the resolved upstream; /v1/fn/* reports as 'fn-invoke', busiest first, observational only). Read-only. Call this before a bulk run to self-check remaining quota: the Free tier is hard-capped (429 quota_exceeded when exhausted); paid tiers are never cut off mid-month. Needs usage:read or features:read.",
14225
+ description: "Current-month usage for this project: requests used, the plan's included quota, remaining (null when the plan has no fixed cap), month-to-date per-feature metered detail (may lag \u2014 it is aggregated periodically), and `routes` \u2014 the same request count split by the surface that served it (the resolved upstream; /v1/fn/* reports as 'fn-invoke', busiest first, observational only), `files` (live private objects and bytes vs the storage `limit` uploads are held to \u2014 422 quota_exceeded at it) and `functions` (today's UTC call count vs the plan's `limit_per_day`; a snapshot that can trail by a minute or two, null when there is no snapshot for today). Read-only. Call this before a bulk run to self-check remaining quota: the Free tier is hard-capped (429 quota_exceeded when exhausted); paid tiers have no monthly cap and are never billed for overage. Needs usage:read or features:read.",
13886
14226
  inputSchema: { type: "object", properties: {} },
13887
14227
  method: "GET",
13888
14228
  path: "/v1/usage"
@@ -14440,6 +14780,40 @@ function notificationsProviderChecks(manifest) {
14440
14780
  }
14441
14781
  return [{ name: "notifications provider", ok: true, warn: true, detail: MOCK_PROVIDER_NOTE }];
14442
14782
  }
14783
+ function authMailDependencyChecks(auth, notificationsEnabled) {
14784
+ if (!auth || auth.enabled === false || notificationsEnabled) return [];
14785
+ const flows = [
14786
+ auth.methods?.magicLink !== false ? "magic link" : null,
14787
+ auth.otp?.enabled === true ? "email OTP" : null,
14788
+ auth.methods?.emailPassword !== false ? "password reset" : null,
14789
+ auth.emailVerification?.required === true ? "email verification" : null
14790
+ ].filter((f) => f !== null);
14791
+ if (!flows.length) return [];
14792
+ return [{
14793
+ name: "auth mail",
14794
+ ok: true,
14795
+ warn: true,
14796
+ detail: `auth sends ${flows.join(", ")} mail through the notifications feature, which is not enabled: those requests answer 502 email_dispatch_failed. Enable notifications (features.notifications in vxil.config, then \`vxil push\`), or turn those auth methods off`
14797
+ }];
14798
+ }
14799
+ var EMBED_MOCK_NOTE = "vector-search embed provider is 'mock': the deterministic TEST embedder ranks by a hash of the text, not by meaning, so search and rag results look real but are not semantic (responses carry embed_provider: 'mock'). Store a provider key (`vxil secrets set vector-search/<name>`) and set embed: { provider: 'openai' | 'cohere', apiKeyRef } in your vector-search config (or 'byov' to supply your own vectors) before you ship";
14800
+ function embedProviderChecks(manifest, collections) {
14801
+ if (!manifest || manifest.enabled === false) return [];
14802
+ const def = manifest.embed?.provider ?? "mock";
14803
+ const mockCols = (collections ?? []).filter((c) => (c.provider ?? def) === "mock").map((c) => c.collection);
14804
+ const staleCols = (collections ?? []).filter((c) => c.embed_cfg_stale === true && !mockCols.includes(c.collection)).map((c) => c.collection);
14805
+ if (def !== "mock" && !mockCols.length && !staleCols.length) {
14806
+ return [{ name: "vector-search embed provider", ok: true, detail: def }];
14807
+ }
14808
+ const parts = [];
14809
+ if (def === "mock" || mockCols.length) {
14810
+ parts.push(EMBED_MOCK_NOTE + (mockCols.length ? `. Collections on 'mock': ${mockCols.join(", ")} \u2014 a collection keeps the provider it was created with: create it again with the real embed provider (same name and dimensions) and re-ingest its documents` : ""));
14811
+ }
14812
+ if (staleCols.length) {
14813
+ parts.push(`Collections pinned to an older embed config: ${staleCols.join(", ")} \u2014 a collection keeps the embed config it was created with, so the current default (${def}) does not reach it: re-create it (same name and dimensions) with the embed you want (omit embed to take the current default), then re-ingest its documents`);
14814
+ }
14815
+ return [{ name: "vector-search embed provider", ok: true, warn: true, detail: parts.join(". ") }];
14816
+ }
14443
14817
  var PAYMENTS_WEBHOOK_SECRET_NOTE = "payments provider is real but no per-tenant webhook secret is declared: every inbound provider delivery will be rejected 401 bad_signature and recorded as a sig_failed row (those rows can never be reprocessed, so the money events behind them are lost). There is NO platform-wide fallback. Declare <provider>.webhookSecretRef in your payments config and store the value with `vxil secrets set payments/<name>`";
14444
14818
  function paymentsWebhookSecretChecks(manifest) {
14445
14819
  if (!manifest || manifest.enabled === false) return [];
@@ -14630,7 +15004,7 @@ function checkPublicKeyScopes(scopes) {
14630
15004
  const denied = scopes.filter((s) => PUBLIC_KEY_DENY_SCOPES.has(s));
14631
15005
  if (!denied.length) return null;
14632
15006
  return {
14633
- error: `a public (thin-client) key cannot carry server-only scope${denied.length > 1 ? "s" : ""}: ${denied.join(", ")} \u2014 keep the public key to end-user data scopes (e.g. cms:read,cms:write,files:write,realtime:write,auth:signin) and mint a separate server key for your backend`
15007
+ error: `a public (thin-client) key cannot carry server-only scope${denied.length > 1 ? "s" : ""}: ${denied.join(", ")} \u2014 keep the public key to end-user data scopes (e.g. cms:read,cms:write,files:write,realtime:write) and mint a separate server key for your backend`
14634
15008
  };
14635
15009
  }
14636
15010
  function parseKeyName(raw) {
@@ -15024,6 +15398,26 @@ async function readAllStdin(stream = process.stdin) {
15024
15398
  return Buffer.concat(chunks).toString("utf8");
15025
15399
  }
15026
15400
 
15401
+ // src/seed.ts
15402
+ function newSeedTally() {
15403
+ return { created: 0, present: 0, failed: 0 };
15404
+ }
15405
+ function tallySeed(t, status, n, code) {
15406
+ if (status >= 200 && status < 300) t.created += n;
15407
+ else if (status === 409 && code === "unique_violation") t.present += n;
15408
+ else t.failed += n;
15409
+ }
15410
+ function formatSeedTally(t, before) {
15411
+ const created = t.created - before.created;
15412
+ const present = t.present - before.present;
15413
+ const failed = t.failed - before.failed;
15414
+ return [
15415
+ `${created} created`,
15416
+ ...present ? [`${present} already present`] : [],
15417
+ ...failed ? [`${failed} failed`] : []
15418
+ ].join(" \xB7 ");
15419
+ }
15420
+
15027
15421
  // src/devLifecycle.ts
15028
15422
  async function devDown(dash, cookie, dev) {
15029
15423
  const r = await dash("DELETE", `/dashboard/tenants/${dev.tenant_id}`, { confirm: dev.slug }, cookie);
@@ -15140,7 +15534,8 @@ async function createBranchTenant(dash, opts) {
15140
15534
  app_name: opts.appName,
15141
15535
  features: opts.features.length ? opts.features : ["cms"],
15142
15536
  kind: "dev",
15143
- ttl_hours: opts.ttlHours
15537
+ ttl_hours: opts.ttlHours,
15538
+ ...opts.inviteCode ? { invite_code: opts.inviteCode } : {}
15144
15539
  });
15145
15540
  if (r.status !== 201 && r.status !== 200) {
15146
15541
  const e = r.json.error;
@@ -15158,6 +15553,56 @@ async function createBranchTenant(dash, opts) {
15158
15553
  ...d.expires_at ? { expires_at: d.expires_at } : {}
15159
15554
  };
15160
15555
  }
15556
+ function slugFromName(name) {
15557
+ const s = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
15558
+ return s.length >= 2 ? s : `dev-${s || "app"}`;
15559
+ }
15560
+ async function mintSessionKey(dash, cookie, tenantId, slug) {
15561
+ const mint = await dash("POST", `/dashboard/tenants/${tenantId}/api-keys`, { name: "dev-up" }, cookie);
15562
+ if (mint.status === 401) return { unauthorized: true };
15563
+ if (mint.status !== 201 && mint.status !== 200) throw new Error(`could not mint a key for '${slug}' (${errText(mint)})`);
15564
+ const apiKey = data(mint).api_key;
15565
+ if (!apiKey) throw new Error("mint returned no api_key");
15566
+ return { api_key: apiKey };
15567
+ }
15568
+ async function createDevTenantViaSession(dash, cookie, opts) {
15569
+ const base2 = slugFromName(opts.appName);
15570
+ const suffix = opts.suffix ?? (() => Math.random().toString(36).slice(2, 8));
15571
+ for (let attempt = 0; attempt < 4; attempt++) {
15572
+ const slug = attempt === 0 ? base2 : `${base2.slice(0, 52)}-${suffix()}`;
15573
+ const r = await dash("POST", "/dashboard/tenants", {
15574
+ slug,
15575
+ display_name: opts.appName,
15576
+ kind: "dev",
15577
+ ttl_hours: opts.ttlHours
15578
+ }, cookie);
15579
+ if (r.status === 401) return { unauthorized: true };
15580
+ if (r.status === 409) continue;
15581
+ if (r.status !== 201 && r.status !== 200) throw new Error(`dev tenant create failed (${errText(r)})`);
15582
+ const t = data(r);
15583
+ if (!t.id) throw new Error("dev tenant create returned no id");
15584
+ const key = await mintSessionKey(dash, cookie, t.id, t.slug ?? slug);
15585
+ if ("unauthorized" in key) return key;
15586
+ return { tenant: { tenant_id: t.id, slug: t.slug ?? slug, api_key: key.api_key, ...t.expires_at ? { expires_at: t.expires_at } : {} } };
15587
+ }
15588
+ throw new Error(`dev tenant create failed: the slug '${base2}' and its suffixed variants are taken \u2014 pass --name <other>`);
15589
+ }
15590
+ async function reuseDevTenantBySlug(dash, cookie, slug) {
15591
+ const list = await dash("GET", "/dashboard/tenants", void 0, cookie);
15592
+ if (list.status === 401) return { unauthorized: true };
15593
+ if (list.status !== 200) throw new Error(`could not list projects (${errText(list)})`);
15594
+ const rows = data(list).tenants ?? [];
15595
+ const t = rows.filter((x) => x.kind === "dev" && (x.slug === slug || x.display_name === slug)).at(-1);
15596
+ if (!t) {
15597
+ if (rows.some((x) => x.slug === slug)) {
15598
+ throw new Error(`'${slug}' is a standard project, not a dev tenant \u2014 --reuse-slug only reuses dev tenants. To use it as the dev slot deliberately, run \`vxil link ${slug} --as dev\`.`);
15599
+ }
15600
+ return { tenant: null };
15601
+ }
15602
+ const key = await mintSessionKey(dash, cookie, t.id, t.slug);
15603
+ if ("unauthorized" in key) return key;
15604
+ return { tenant: { tenant_id: t.id, slug: t.slug, api_key: key.api_key, ...t.expires_at ? { expires_at: t.expires_at } : {} } };
15605
+ }
15161
15606
 
15162
15607
  // src/try.ts
15163
15608
  async function mintTry(fetchImpl, base2, opts = {}) {
@@ -15219,10 +15664,10 @@ function renderTryReport(result, opts) {
15219
15664
  if (genOk) {
15220
15665
  lines.push("\n try it (types were generated in ./vxil.types.ts):");
15221
15666
  lines.push(" import { Vxil } from '@vxil/sdk';");
15222
- lines.push(" import type { VxilSchema } from './vxil.types';");
15667
+ lines.push(" import type { VxilSchema } from './vxil.types.js';");
15223
15668
  lines.push(` const vx = Vxil.connect<VxilSchema>({ apiKey: '${result.api_key}', baseUrl: '${opts.base}' });`);
15224
15669
  } else {
15225
- lines.push("\n try it (typed client skipped \u2014 `npm i -D vxil && npm i @vxil/sdk`, then `npx vxil gen`):");
15670
+ lines.push("\n try it (typed client skipped \u2014 `npm i -D @vxil/cli && npm i @vxil/sdk`, then `npx vxil gen`):");
15226
15671
  lines.push(" import { Vxil } from '@vxil/sdk';");
15227
15672
  lines.push(` const vx = new Vxil({ apiKey: '${result.api_key}', baseUrl: '${opts.base}' });`);
15228
15673
  }
@@ -15238,9 +15683,9 @@ function renderTryReport(result, opts) {
15238
15683
  var SELF_HELP_VERBS = /* @__PURE__ */ new Set(["keys", "account", "members", "api", "migrate"]);
15239
15684
  var VERB_USAGE = {
15240
15685
  version: "usage: vxil --version \u2014 print the CLI version",
15241
- init: "usage: vxil init [--template <id>] [--force] \u2014 scaffold vxil.config.ts + functions/ + .vxil/ from a Blueprint Gallery template (`vxil templates` lists them)",
15686
+ init: "usage: vxil init [<dir>] [--template <id>] [--force] \u2014 scaffold vxil.config.ts + functions/ + .vxil/ from a Blueprint Gallery template (`vxil templates` lists them) into <dir> (created when missing) or the current folder",
15242
15687
  templates: "usage: vxil templates \u2014 list the Blueprint Gallery (`vxil init --template <id>` scaffolds one)",
15243
- quickstart: "usage: vxil quickstart [--email <e>] [--password <p>] [--name <display>] [--features a,b] [--invite <code>] [--env <label>] [--dev [--ttl <h>]] [--no-push] \u2014 create a project + key + enable features in one call",
15688
+ quickstart: "usage: vxil quickstart [--email <e>] [--password <p>] [--name <display>] [--features a,b] [--invite <code>] [--workload production|staging|development] [--env <label>] [--dev [--ttl <h>]] [--no-push] \u2014 create a project + key + enable features in one call (workload: staging unless --workload / --env / --dev says otherwise)",
15244
15689
  try: "usage: vxil try \u2014 a keyless anonymous sandbox backend (no account); self-expires",
15245
15690
  login: "usage: vxil login [--email <e>] [--password <p>] \u2014 store a dashboard session in ~/.vxil (bound to the dashboard it came from)",
15246
15691
  link: "usage: vxil link <slug> [--key <api_key> [--tenant <id>]] [--as dev|<target-name>] [--env <label>] [--mint] \u2014 bind this repo to a project; --tenant is checked against the key's own tenant and refused when the key's tenant cannot be read",
@@ -15268,14 +15713,14 @@ var VERB_USAGE = {
15268
15713
  pull: "usage: vxil pull [--dev|--target <name>] [--out <file=vxil.config.ts>] [--force] \u2014 materialize config + cms schema from the live project",
15269
15714
  env: "usage: vxil env pull [--dev | --target <name>] [--file <path=.env.local>] [--print] [--no-gitignore]",
15270
15715
  secrets: "usage: vxil secrets set <feature>/<name> [--value -] \xB7 secrets list [--all-projects] \xB7 secrets rm <feature>/<name> [--yes] (set reads piped stdin or a hidden prompt)",
15271
- seed: "usage: vxil seed [--dev|--target <name>] \u2014 apply the config `seed` block through import bundles",
15716
+ seed: "usage: vxil seed [--dev|--target <name>] \u2014 apply the config `seed` block (users via /v1/users/bulk, cms items one POST each; a 409 unique conflict counts as already present; exit 1 on any failed row; `--dev` with no seed block is a no-op)",
15272
15717
  export: "usage: vxil export <feature> [--out <file>] [--ndjson] [--urls [--pace <seconds>]] \u2014 exit 0 complete \xB7 1 error \xB7 3 download URLs expired before they could be read \xB7 4 incomplete (a table was cut at the server cap with no resume cursor; the file is still written)",
15273
- import: "usage: vxil import <feature> --file <bundle.json> [--mode merge|replace]",
15718
+ import: "usage: vxil import <feature> --file <bundle.json|export.ndjson> [--mode merge|replace] [--allow-split-links]",
15274
15719
  migrate: "usage: vxil migrate plan|schema|data|verify|payments \u2026 (run `vxil migrate --help`)",
15275
15720
  functions: "usage: vxil functions new|deploy [<name>] [--force]|list|delete|enable|disable [--yes]|invoke ['<json>' | --data '<json>'] [--async]|logs [--tail]|dev <name> [--port <n>] [--trigger <lane>] [--event <file.json>]",
15276
15721
  cms: "usage: vxil cms status|pull|reindex <collection> [--field <field>]|drop <collection> [--yes]|bulk-rm <collection> --filter '<json>' [--limit <1..100>] [--dry-run] [--yes]",
15277
15722
  payments: "usage: vxil payments simulate --scenario <refund-pair|cross-platform-unlock|renewal|expiry|past-due-grace|transfer> --user <id> [--tier <t>] [--dev] \u2014 mock/dev projects only",
15278
- dev: "usage: vxil dev up [--ttl <h>] [--new] | down [--yes] | seed | reset | branch [<name>] [--ttl <h=24>] [--no-env] [--print] | --list | --rm <name> [--yes] | --watch [--target <name>]",
15723
+ dev: "usage: vxil dev up [--ttl <h>] [--new] [--name <n>] [--reuse-slug <slug>] [--email <e> --password <p>] [--invite <code>] | down [--yes] | seed | reset | branch [<name>] [--ttl <h=24>] [--no-env] [--print] | --list | --rm <name> [--yes] | --watch [--target <name>] \u2014 dev up signs in with --email/--password (or VXIL_DEV_EMAIL/VXIL_DEV_PASSWORD, + VXIL_DEV_INVITE), else your `vxil login` session, else a prompt",
15279
15724
  config: "usage: vxil config get <feature> \xB7 vxil config draft show|save [--file <f>]|publish|discard <feature> \xB7 vxil config draft duplicate <feature> --from <version>",
15280
15725
  users: "usage: vxil users add <id> --email <email> \xB7 users list \xB7 users import <users.json|users.csv> [--json]",
15281
15726
  send: "usage: vxil send <user_id> <template> --data '<json>'",
@@ -15299,6 +15744,126 @@ function normalizeHelpArgv(rest2) {
15299
15744
  function isGlobalHelp(cmd2) {
15300
15745
  return cmd2 === void 0 || cmd2 === "help" || cmd2 === "--help" || cmd2 === "-h";
15301
15746
  }
15747
+ function flagsIn(text) {
15748
+ return [...text.matchAll(/--([a-z][a-z0-9-]*)/g)].map((m) => m[1]);
15749
+ }
15750
+ var EXTRA_KNOWN_FLAGS = [
15751
+ // globals
15752
+ "json",
15753
+ "help",
15754
+ "version",
15755
+ // keys / account / members (their own usage blocks)
15756
+ "allow",
15757
+ "deny",
15758
+ "clear-allow",
15759
+ "clear-deny",
15760
+ "class",
15761
+ "end-user-required",
15762
+ "store",
15763
+ "role",
15764
+ "all",
15765
+ // migrate (its own usage block)
15766
+ "apply",
15767
+ "execute",
15768
+ "assume-schema",
15769
+ "auth-table",
15770
+ "batch",
15771
+ "customers",
15772
+ "dir",
15773
+ "file-links",
15774
+ "from-provider",
15775
+ "include-empty",
15776
+ "include-password-hashes",
15777
+ "with-password-hashes",
15778
+ "keep-source-id",
15779
+ "oidc-issuer",
15780
+ "only",
15781
+ "pg-schema",
15782
+ "profile-table",
15783
+ "prune",
15784
+ "since",
15785
+ "skip",
15786
+ "schemas",
15787
+ "to-postgres",
15788
+ "url",
15789
+ // operator / misc verbs
15790
+ "display-name",
15791
+ "feature",
15792
+ "locale",
15793
+ "run-id",
15794
+ "turnstile"
15795
+ ];
15796
+ var KNOWN_FLAGS = /* @__PURE__ */ new Set([
15797
+ ...flagsIn(Object.values(VERB_USAGE).join(" ")),
15798
+ ...EXTRA_KNOWN_FLAGS,
15799
+ ...Object.values(PROMOTION_OVERRIDE_FLAGS)
15800
+ ]);
15801
+ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
15802
+ "json",
15803
+ "help",
15804
+ "force",
15805
+ "yes",
15806
+ "new",
15807
+ "dev",
15808
+ "prod",
15809
+ "dry-run",
15810
+ "list",
15811
+ "tail",
15812
+ "async",
15813
+ "mint",
15814
+ "offline",
15815
+ "purge",
15816
+ "raw",
15817
+ "strict",
15818
+ "strict-api-state",
15819
+ "strict-secrets",
15820
+ "sweep",
15821
+ "watch",
15822
+ "no-env",
15823
+ "no-gen",
15824
+ "no-gitignore",
15825
+ "no-mcp",
15826
+ "no-push",
15827
+ "print",
15828
+ "ndjson",
15829
+ "explain",
15830
+ "check",
15831
+ "all-projects",
15832
+ "allow-destructive",
15833
+ "exit-code",
15834
+ "show-runs",
15835
+ "skip-functions",
15836
+ "server",
15837
+ "urls"
15838
+ ]);
15839
+ function positionalArgs(rest2, skip = 0) {
15840
+ const out = [];
15841
+ for (let i = 0; i < rest2.length; i++) {
15842
+ const t = rest2[i];
15843
+ if (t.startsWith("--")) {
15844
+ const name = t.slice(2);
15845
+ if (!BOOLEAN_FLAGS.has(name) && !name.includes("=") && rest2[i + 1] && !rest2[i + 1].startsWith("--")) i++;
15846
+ continue;
15847
+ }
15848
+ out.push(t);
15849
+ }
15850
+ return out.slice(skip);
15851
+ }
15852
+ function unknownFlags(rest2) {
15853
+ const out = [];
15854
+ for (let i = 0; i < rest2.length; i++) {
15855
+ const t = rest2[i];
15856
+ if (t === "--") break;
15857
+ if (!t.startsWith("--")) continue;
15858
+ const name = t.slice(2).split("=")[0];
15859
+ if (!KNOWN_FLAGS.has(name)) {
15860
+ if (!out.includes(`--${name}`)) out.push(`--${name}`);
15861
+ } else if (!t.includes("=") && !BOOLEAN_FLAGS.has(name)) {
15862
+ i++;
15863
+ }
15864
+ }
15865
+ return out;
15866
+ }
15302
15867
 
15303
15868
  // src/keyTenant.ts
15304
15869
  function knownTenantId(id) {
@@ -15335,10 +15900,16 @@ function keyTenantCheck(v) {
15335
15900
  }
15336
15901
 
15337
15902
  // src/pushOutcome.ts
15338
- function pushExitCode(p, strict) {
15903
+ function scopeHint(p, slug) {
15904
+ if (![...p.hard, ...p.strict].some((x) => x.includes("insufficient_scope"))) return null;
15905
+ return "hint: the pushing key lacks a scope for a feature this config uses (a quickstart key carries only the features declared at quickstart) \u2014 " + (slug ? `mint a fresh key with \`vxil link ${slug} --mint\` (your session's full key set, binding kept), then push again` : "mint one that holds the scopes named above (`vxil keys mint --name <n> --scopes <a,b> --store`), then push again");
15906
+ }
15907
+ function pushExitCode(p, strict, opts = {}) {
15339
15908
  const lines = [];
15340
15909
  for (const h of p.hard) lines.push(`push problem: ${h}`);
15341
15910
  for (const s of p.strict) lines.push(`${strict ? "push problem (--strict)" : "warning"}: ${s}`);
15911
+ const hint = scopeHint(p, opts.slug);
15912
+ if (hint) lines.push(hint);
15342
15913
  const failing = p.hard.length + (strict ? p.strict.length : 0);
15343
15914
  if (failing > 0) {
15344
15915
  lines.push(`push applied, but ${failing} problem(s) remain \u2014 exit 1${!strict && p.strict.length ? ` (${p.strict.length} more would fail under --strict)` : ""}`);
@@ -16470,7 +17041,7 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
16470
17041
  }
16471
17042
  }
16472
17043
  if (res.status === 404) {
16473
- result.errors.push(apiErrorLine(`cms ${table}: collection '${collection}' not found`, res) + " \u2014 run `vxil migrate schema --execute` first");
17044
+ result.errors.push(apiErrorLine(`cms ${table}: collection '${collection}' not found`, res) + " \u2014 run `vxil migrate schema --apply` first");
16474
17045
  result.cursor = cursors[key] ?? null;
16475
17046
  save();
16476
17047
  return result;
@@ -17763,10 +18334,10 @@ async function runData(deps, plan, adapter, opts = {}) {
17763
18334
  if (cmsWanted.length > 0 && !g.state.schemaPushed && opts.assumeSchema !== true) {
17764
18335
  if (g.execute) {
17765
18336
  throw new Error(
17766
- "cms: the schema step has not run \u2014 run `vxil migrate schema --execute` first (collections must exist before rows), or pass assumeSchema when they already do."
18337
+ "cms: the schema step has not run \u2014 run `vxil migrate schema --apply` first (collections must exist before rows), or pass --assume-schema when they already do."
17767
18338
  );
17768
18339
  }
17769
- g.deps.log("\u26A0 cms: schema not pushed yet \u2014 dry-run proceeds; run `vxil migrate schema --execute` before `data --execute`");
18340
+ g.deps.log("\u26A0 cms: schema not pushed yet \u2014 dry-run proceeds; run `vxil migrate schema --apply` before `data --execute`");
17770
18341
  }
17771
18342
  for (const table of cmsWanted) {
17772
18343
  const r = await loadCmsTable({
@@ -17891,6 +18462,37 @@ ${entry}
17891
18462
  }
17892
18463
  var CMS_DEFAULT_MAX_COLLECTIONS = 25;
17893
18464
  var CMS_MAX_COLLECTIONS_CEILING = 200;
18465
+ function planFeatureNeeds(plan, opts = {}) {
18466
+ const out = [];
18467
+ const steps = opts.only?.length ? new Set(opts.only) : null;
18468
+ const want = (s, tables) => !steps || steps.has(s) || tables.some((t) => steps.has(t));
18469
+ const byTarget = (t) => plan.tables.filter((x) => x.target === t).map((x) => x.table);
18470
+ const cms = byTarget("cms");
18471
+ if (cms.length && want("cms", cms)) out.push({ feature: "cms", why: `${cms.length} table(s) map to collections` });
18472
+ if ((plan.vectorTargets ?? []).length && want("vectors", [])) {
18473
+ out.push({ feature: "vector-search", why: `${plan.vectorTargets.length} vector column(s) carried` });
18474
+ }
18475
+ if (opts.step === "schema") return out;
18476
+ const fileFields = plan.tables.some((t) => t.fields.some((f) => f.storageObject));
18477
+ const files = byTarget("files");
18478
+ if ((files.length || fileFields || (plan.storageBuckets ?? []).length) && want("files", files)) {
18479
+ out.push({ feature: "files", why: "storage objects are re-keyed through the files upload" });
18480
+ }
18481
+ const pay = byTarget("payments-credits");
18482
+ if (pay.length && want("payments", pay)) out.push({ feature: "payments", why: `${pay.length} table(s) map to payments credits` });
18483
+ if (opts.credentials && want("credentials", [])) out.push({ feature: "auth", why: "the credentials step imports password hashes / OIDC links" });
18484
+ return out;
18485
+ }
18486
+ async function missingPlanFeatures(api, needs) {
18487
+ if (!needs.length) return [];
18488
+ const res = await api("GET", "/v1/features");
18489
+ const live = res.status === 200 ? res.body.data?.features : void 0;
18490
+ if (!Array.isArray(live)) return [];
18491
+ return needs.filter((n) => !live.includes(n.feature));
18492
+ }
18493
+ function missingFeaturesText(missing) {
18494
+ return `this project does not run ${missing.map((m) => `${m.feature} (${m.why})`).join(", ")} \u2014 add them to features in vxil.config.ts (migrate/vxil.config.suggested.ts lists the blocks) and \`vxil push\`, then re-run`;
18495
+ }
17894
18496
  function emitSuggestedConfig(plan, sourceLabel) {
17895
18497
  const collections = planToCollections(plan);
17896
18498
  const n = Object.keys(collections).length;
@@ -17912,6 +18514,9 @@ function emitSuggestedConfig(plan, sourceLabel) {
17912
18514
  ...sync.length > 0 ? { sync } : {}
17913
18515
  };
17914
18516
  }
18517
+ for (const n2 of planFeatureNeeds(plan, { credentials: plan.tables.some((t) => t.target === "users") })) {
18518
+ if (!(n2.feature in features)) features[n2.feature] = {};
18519
+ }
17915
18520
  const cfg = { features, cms: { collections } };
17916
18521
  const notes = [];
17917
18522
  if (cms.limits) {
@@ -18075,6 +18680,8 @@ function loaderDeps({ api, cwd, log }, dryRun) {
18075
18680
  async function runMigrateSchema(io, opts = {}) {
18076
18681
  const plan = readPlanFile(io.cwd);
18077
18682
  const apply = opts.apply === true;
18683
+ const missing = apply ? await missingPlanFeatures(io.api, planFeatureNeeds(plan, { step: "schema" })) : [];
18684
+ if (missing.length) throw new Error(`migrate schema --apply refused: ${missingFeaturesText(missing)} (nothing was written)`);
18078
18685
  if (apply) ensureStateIgnored(io.cwd, io.log);
18079
18686
  const result = await runSchema(loaderDeps(io, !apply), plan, { execute: apply });
18080
18687
  if (!apply) io.log("\n(dry-run \u2014 re-run with --apply to create the collections)");
@@ -18109,6 +18716,12 @@ async function runMigrateData(io, opts = {}) {
18109
18716
  io.log(` \u26A0 migrate/payments.json names '${table}' but plan.json maps it to ${m.target} \u2014 re-run \`vxil migrate plan\` to plan it as payments credits`);
18110
18717
  }
18111
18718
  }
18719
+ const missing = execute ? await missingPlanFeatures(io.api, planFeatureNeeds(plan, {
18720
+ step: "data",
18721
+ ...opts.only ? { only: opts.only } : {},
18722
+ credentials: credentials !== void 0
18723
+ })) : [];
18724
+ if (missing.length) throw new Error(`migrate data --execute refused: ${missingFeaturesText(missing)} (nothing was written)`);
18112
18725
  const prior = loadMigrateState(p.stateFile);
18113
18726
  const hasProgress = Object.keys(prior.cursors).length > 0 || Object.keys(prior.idMap).length > 0;
18114
18727
  if (execute && hasProgress && opts.resume !== true) {
@@ -18524,7 +19137,7 @@ function keyRef(client, scope, explicitKey) {
18524
19137
  if (scope === "project" && client === "claude") {
18525
19138
  return {
18526
19139
  header: "Bearer ${VXIL_API_KEY}",
18527
- note: "the entry references ${VXIL_API_KEY} \u2014 export it in your shell (e.g. from `vxil env pull`'s .env.local) so no key lands in the shareable .mcp.json"
19140
+ note: "the entry references ${VXIL_API_KEY} \u2014 export a DEDICATED agent key in the shell that runs claude, e.g. `vxil keys mint --name mcp-claude --scopes features:read,<the feature scopes the agent needs>` (revocable on its own; every MCP key needs features:read), so no key lands in the shareable .mcp.json"
18528
19141
  };
18529
19142
  }
18530
19143
  if (scope === "project" && client === "vscode") {
@@ -18589,6 +19202,10 @@ function claudeAddArgv(url, keyOrPlaceholder, serverName = "vxil") {
18589
19202
  url
18590
19203
  ];
18591
19204
  }
19205
+ function mcpKeyScopes(scopesFlag) {
19206
+ const scopes = scopesFlag.split(",").map((s) => s.trim()).filter(Boolean);
19207
+ return scopes.includes("features:read") ? scopes : ["features:read", ...scopes];
19208
+ }
18592
19209
 
18593
19210
  // src/generated/templates.ts
18594
19211
  var TEMPLATE_CATALOG = [
@@ -18875,13 +19492,11 @@ export default defineConfig({
18875
19492
  "functions"
18876
19493
  ],
18877
19494
  "hasFunctions": true,
18878
- "byoKeys": [
18879
- "vxil_read_key"
18880
- ],
18881
- "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// \"Agent Desk\" \u2014 the AGENT blueprint. A deliberately small support desk that\n// exercises the whole AI half, each piece doing exactly one job:\n//\n// \u2022 ai classify \u2192 what KIND of ticket is this? (a forced-label verdict)\n// \u2022 ai judge \u2192 how GOOD is this draft? (a forced-score verdict)\n// \u2022 vector-search\u2192 the knowledge index, kept in step with the `kb` collection\n// \u2022 rag \u2192 a grounded, CITED answer over that index\n// \u2022 copilot \u2192 the in-app assistant, with a propose \u2192 confirm action\n// catalog so a write never happens behind the user's back\n// \u2022 mcp \u2192 the same backend as TOOLS, narrowed to a least-privilege\n// agent key\n// \u2022 functions \u2192 the three deterministic steps: triage on write, draft on\n// a button, and a capability probe an agent can call to\n// discover what this backend can react to\n//\n// Everything runs on the deterministic `mock` AI provider until you add a key,\n// so the walkthrough is reproducible with no provider account.\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 // The model layer. `mock` is deterministic \u2014 swap `defaultProvider` and add\n // ONE key reference to run the same config on a real model.\n ai: {\n enabled: true,\n defaultProvider: 'mock',\n // providers: { openaiKeyRef: 'openai_key' }, // then defaultProvider: 'openai'\n defaults: { model: 'mock-1', maxTokens: 512, temperature: 0 },\n cache: { ttlSeconds: 0 },\n streaming: { enabled: true },\n },\n\n // The knowledge index. `mock` embeddings need no key; auto-sync keeps the\n // index in step with the `kb` cms collection so there is no second place to\n // remember to update.\n 'vector-search': {\n enabled: true,\n embed: { provider: 'mock', dimensions: 1536 },\n chunking: { maxTokens: 512, overlap: 64 },\n hybrid: { defaultMode: 'hybrid' },\n sync: [\n {\n source: 'cms',\n cmsCollection: 'kb', // the collection you author in\n collection: 'kb', // the index rag reads\n fields: ['title', 'body'], // what gets embedded\n metadataFields: ['topic'], // \u2026and what stays filterable\n statusFilter: 'published', // drafts are never indexed\n cron: '*/15 * * * *',\n },\n ],\n },\n\n // Retrieval \u2192 grounded answer. NOTE: `defaultTemplate` names a PROMPT\n // TEMPLATE, which is a versioned row you create with\n // `POST /v1/ai/templates` \u2014 prompts are yours, not config (see the README).\n rag: {\n enabled: true,\n defaultCollection: 'kb',\n defaultTemplate: 'support-answer',\n retrieval: { topK: 5, mode: 'hybrid' },\n context: { maxTokens: 2000, strategy: 'topk' },\n citations: true,\n streaming: true,\n },\n\n // The in-app assistant. `actions.mode: 'actions'` is what turns a read-only\n // chat into one that can PROPOSE a write; `requireConfirm` is what makes\n // the human the one who commits it.\n copilot: {\n enabled: true,\n agents: {\n desk: {\n name: 'Desk',\n tone: 'Concise, factual, never speculative.',\n greeting: 'Ask me about a ticket, or about anything in the knowledge base.',\n knowledge: { collections: ['kb'], useRag: true },\n citations: true,\n grounding: 'strict', // answer only from retrieved context\n actions: {\n mode: 'actions',\n // The keys ARE tool names from the catalog \u2014 an unknown key is\n // inert, never invented. Reads run inline; writes are proposed.\n allow: {\n cms_query_items: { requireConfirm: false },\n cms_create_item: { requireConfirm: true },\n cms_run_item_action: { requireConfirm: true },\n },\n },\n guardrails: {\n maxTurnsPerSession: 20,\n rateLimitPerUserPerDay: 50,\n maxInputChars: 4000,\n refusalMessage: \"I can only answer from this workspace's knowledge base.\",\n allowGuest: false,\n },\n },\n },\n limits: { consumeCredits: false },\n widget: { enabled: false, requireAuth: true, allowedOrigins: [] },\n },\n\n // The same backend, exposed as TOOLS. `custom` + an explicit list is the\n // least-privilege posture: the agent sees these and nothing else.\n mcp: {\n enabled: true,\n exposureLevel: 'custom',\n allowToolList: [\n 'cms_query_items',\n 'cms_create_item',\n 'cms_run_item_action',\n 'ai_classify',\n 'ai_judge',\n 'rag_answer',\n 'rag_search',\n 'search_query',\n 'webhooks_event_catalog',\n 'vxil_tool_search',\n ],\n rateLimits: { toolCallsPerMin: 60 },\n branding: {\n serverName: 'Agent Desk',\n serverInstructions:\n 'Answer from the knowledge base and cite it. Classify a ticket before replying. '\n + 'Never create or modify a record without the user confirming it first.',\n },\n // Config-declared prompts the agent can pull instead of you pasting one.\n prompts: {\n triage: {\n description: 'Triage an inbound ticket end to end.',\n template:\n 'Classify ticket {{ticket_id}} with ai_classify, then use rag_answer to draft a reply '\n + 'grounded in the knowledge base. Show me the draft and its citations before writing anything.',\n arguments: [{ name: 'ticket_id', description: 'the cms item id', required: true }],\n },\n },\n },\n\n // Enabled for ONE reason in this blueprint: the event CATALOG \u2014 the\n // machine-readable list of everything this backend can emit, which is how\n // an agent discovers what it is able to react to.\n webhooks: { enabled: true, maxSubscriptions: 5 },\n\n cms: {},\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n tickets: {\n singular: 'ticket',\n // ONE button per row. Pressing \"Draft reply\" runs the rag + judge step\n // once, as a human-initiated action \u2014 not on every write.\n actions: [{ key: 'draft_reply', label: 'Draft reply', fn: 'draft-reply' }],\n fields: {\n subject: { type: 'string', required: true, indexSlot: 's1' },\n requester: { type: 'string', indexSlot: 's2' },\n // written by the triage function, never by hand\n category: { type: 'string', indexSlot: 's3' }, // billing | bug | how_to | other\n state: { type: 'string', indexSlot: 's4' }, // open | drafted | closed\n draft_score: { type: 'int', indexSlot: 'n1' }, // the judge's score, 0\u201310\n created_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text' },\n draft: { type: 'text' }, // the last grounded draft reply\n },\n },\n\n kb: {\n singular: 'kb_article',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', unique: true, indexSlot: 's2' },\n topic: { type: 'string', indexSlot: 's3' },\n published_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // TRIAGE ON WRITE. Fires on every new ticket, re-fetches it (a hook\n // delivery carries ids, not the row), asks for a forced-label verdict, and\n // writes the label back. One model call, one field.\n 'triage-ticket': {\n entry: './functions/triage-ticket.ts',\n trigger: { kind: 'cmsHook', collection: 'tickets', event: 'beforeCreate' },\n scopes: ['cms:read', 'cms:write', 'ai:write'],\n egressAllow: [],\n },\n\n // DRAFT ON DEMAND. Invoked by the `draft_reply` record action: retrieve \u2192\n // ground \u2192 answer with citations, then score the draft against a rubric\n // before a human ever sees it.\n 'draft-reply': {\n entry: './functions/draft-reply.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'rag:write', 'ai:write'],\n egressAllow: [],\n },\n\n // CAPABILITY DISCOVERY. Returns the event names this backend can emit,\n // folded to the features it actually has on \u2014 the answer to an agent's\n // \"what can I react to here?\". The catalog is a control-plane read, so it\n // uses a narrow BYO key rather than the function's feature callback.\n 'agent-capabilities': {\n entry: './functions/agent-capabilities.ts',\n trigger: { kind: 'http' },\n scopes: [],\n secrets: ['secret:vxil_read_key'],\n egressAllow: [],\n },\n },\n\n secrets: {\n vxil_read_key: {\n feature: 'functions',\n description: 'a vxil API key of this backend holding ONLY features:read + webhooks:read',\n },\n // openai_key: { feature: 'ai', description: 'BYO model key \u2014 then set ai.defaultProvider' },\n },\n\n seed: {\n cms: [\n {\n collection: 'kb',\n items: [\n {\n title: 'How refunds work',\n slug: 'how-refunds-work',\n topic: 'billing',\n published_at: '2026-02-10T09:00:00Z',\n body:\n 'A refund is issued to the original payment method within 14 days of purchase. '\n + 'Ask the customer for the order id, confirm the purchase date, then issue the refund '\n + 'from the billing screen. Refunds are not available after 14 days.',\n },\n ],\n },\n ],\n },\n});\n",
18882
- "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\nmkdir my-desk && cd my-desk\nvxil init --template agent-desk\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil login # keys mint needs a dashboard session, not a project key\nvxil keys mint --name desk-capabilities --scopes webhooks:read --json | jq -r .api_key | vxil secrets set functions/vxil_read_key\nvxil push # collections + the three functions; writes vxil.types.ts\nvxil seed # one kb article ("How refunds work", a draft)\n```\n\nThe mint line needs `jq` (it pipes the key straight into the secret, so it never shows in your\nterminal); without it, run the mint with `--json`, copy `api_key`, and paste it at the hidden prompt of\n`vxil secrets set functions/vxil_read_key`.\n\n> **Plan note.** The three functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\nEverything runs on the deterministic **`mock`** model provider and the **`mock`** embedder, so the\nwalkthrough below is reproducible with no provider account and no spend. Going real is two switches,\nnot one (see [Going real](#going-real-the-model-and-the-embedder) at the end): the model, and the\nembedder the `kb` index was created with.\n\n`vxil_read_key` is a key **of this same backend** carrying only `webhooks:read`; the capability probe\nuses it for the event catalog (`GET /v1/features` answers any authenticated key, so it needs no extra\nscope). The key is a holdover in the shipped function, not a platform limit: a function that declares\n`webhooks:read` reads the event catalog through its own scoped callback, with no stored key. (The\nconfig\'s comments ask for `features:read` too; the probe does not need it.)\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: declare them, or create them\n\n`rag.defaultTemplate: \'support-answer\'` names a **prompt template**, a versioned row. You can\n**declare** it in `vxil.config.ts`, and `vxil push` converges it (a new version only when the text\nchanged \u2014 the funnel-saas blueprint\'s "Declared API state" shows the whole table):\n\n```ts\nai: {\n // \u2026the blueprint\'s ai block, plus:\n templates: [{\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},\n```\n\nOr create it over the API when you iterate on a prompt hourly and do not want a push per wording\nchange. Either way it must exist 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`. The project key quickstart minted carries these (`vxil env pull && set -a && . ./.env.local && set +a`, then `KEY=$VXIL_API_KEY`); the `vxil api` lines need no variable at all.\n\n**Reading the output comments.** Each `# 200 { "data": \u2026 }` comment shows the HTTP response. A raw\n`curl` call prints exactly that; a `vxil api` line prints only what is inside `data` (no status, no\nenvelope), ready for `jq` \u2014 so `jq .field`, not `jq .data.field`.\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\nA citation\'s `score` is a **rank** value (about 0.016\u20130.033 for the top hit), not a relevance\nmeasure \u2014 it orders the hits. To decide whether a chunk is close enough to the question, preview\nretrieval with `POST /v1/rag/search` and threshold each hit\'s `similarity` (cosine, \u22121..1) instead.\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": \u2026,\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## Going real: the model and the embedder\n\nNothing in a response says that `mock` produced it, and swapping the model does **not** change the\nembedder: retrieval keeps ranking by a test hash, not by meaning, until you change both.\n\n1. **The model.** `vxil secrets set ai/openai_key`, then set `ai.defaultProvider: \'openai\'` and a real\n model in `ai.defaults.model`, and `vxil push`.\n2. **The embedder.** `vxil secrets set vector-search/openai_key`; set\n `features[\'vector-search\'].embed` to `{ provider: \'openai\', model: \'text-embedding-3-small\', apiKeyRef: \'secret:openai_key\', dimensions: 1536 }`\n and push. The `kb` index keeps the embedder it was **created** with, so re-send the create with the\n same width, which updates it in place:\n ```bash\n vxil api POST /v1/search/collections --data \'{"collection":"kb","dimensions":1536,"embed":{"provider":"openai","model":"text-embedding-3-small","apiKeyRef":"secret:openai_key"}}\'\n ```\n then re-ingest every article (the step 1 ingest per article, or publish them again for the sync to\n pick up), because the vectors already stored were made by `mock`. A different width needs a new\n collection name: a collection\'s width cannot change.\n\n**Pairs with:** the **ai-journal** blueprint (enrichment on write, and asking your own data questions)\nand the **helpdesk** blueprint (the same desk without the AI half).\n',
19495
+ "byoKeys": [],
19496
+ "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// \"Agent Desk\" \u2014 the AGENT blueprint. A deliberately small support desk that\n// exercises the whole AI half, each piece doing exactly one job:\n//\n// \u2022 ai classify \u2192 what KIND of ticket is this? (a forced-label verdict)\n// \u2022 ai judge \u2192 how GOOD is this draft? (a forced-score verdict)\n// \u2022 vector-search\u2192 the knowledge index, kept in step with the `kb` collection\n// \u2022 rag \u2192 a grounded, CITED answer over that index\n// \u2022 copilot \u2192 the in-app assistant, with a propose \u2192 confirm action\n// catalog so a write never happens behind the user's back\n// \u2022 mcp \u2192 the same backend as TOOLS, narrowed to a least-privilege\n// agent key\n// \u2022 functions \u2192 the three deterministic steps: triage on write, draft on\n// a button, and a capability probe an agent can call to\n// discover what this backend can react to\n//\n// Everything runs on the deterministic `mock` AI provider until you add a key,\n// so the walkthrough is reproducible with no provider account.\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 // The model layer. `mock` is deterministic \u2014 swap `defaultProvider` and add\n // ONE key reference to run the same config on a real model.\n ai: {\n enabled: true,\n defaultProvider: 'mock',\n // providers: { openaiKeyRef: 'openai_key' }, // then defaultProvider: 'openai'\n defaults: { model: 'mock-1', maxTokens: 512, temperature: 0 },\n cache: { ttlSeconds: 0 },\n streaming: { enabled: true },\n },\n\n // The knowledge index. `mock` embeddings need no key; auto-sync keeps the\n // index in step with the `kb` cms collection so there is no second place to\n // remember to update.\n 'vector-search': {\n enabled: true,\n embed: { provider: 'mock', dimensions: 1536 },\n chunking: { maxTokens: 512, overlap: 64 },\n hybrid: { defaultMode: 'hybrid' },\n sync: [\n {\n source: 'cms',\n cmsCollection: 'kb', // the collection you author in\n collection: 'kb', // the index rag reads\n fields: ['title', 'body'], // what gets embedded\n metadataFields: ['topic'], // \u2026and what stays filterable\n statusFilter: 'published', // drafts are never indexed\n cron: '*/15 * * * *',\n },\n ],\n },\n\n // Retrieval \u2192 grounded answer. NOTE: `defaultTemplate` names a PROMPT\n // TEMPLATE, which is a versioned row you create with\n // `POST /v1/ai/templates` \u2014 prompts are yours, not config (see the README).\n rag: {\n enabled: true,\n defaultCollection: 'kb',\n defaultTemplate: 'support-answer',\n retrieval: { topK: 5, mode: 'hybrid' },\n context: { maxTokens: 2000, strategy: 'topk' },\n citations: true,\n streaming: true,\n },\n\n // The in-app assistant. `actions.mode: 'actions'` is what turns a read-only\n // chat into one that can PROPOSE a write; `requireConfirm` is what makes\n // the human the one who commits it.\n copilot: {\n enabled: true,\n agents: {\n desk: {\n name: 'Desk',\n tone: 'Concise, factual, never speculative.',\n greeting: 'Ask me about a ticket, or about anything in the knowledge base.',\n knowledge: { collections: ['kb'], useRag: true },\n citations: true,\n grounding: 'strict', // answer only from retrieved context\n actions: {\n mode: 'actions',\n // The keys ARE tool names from the catalog \u2014 an unknown key is\n // inert, never invented. Reads run inline; writes are proposed.\n allow: {\n cms_query_items: { requireConfirm: false },\n cms_create_item: { requireConfirm: true },\n cms_run_item_action: { requireConfirm: true },\n },\n },\n guardrails: {\n maxTurnsPerSession: 20,\n rateLimitPerUserPerDay: 50,\n maxInputChars: 4000,\n refusalMessage: \"I can only answer from this workspace's knowledge base.\",\n allowGuest: false,\n },\n },\n },\n limits: { consumeCredits: false },\n widget: { enabled: false, requireAuth: true, allowedOrigins: [] },\n },\n\n // The same backend, exposed as TOOLS. `custom` + an explicit list is the\n // least-privilege posture: the agent sees these and nothing else.\n mcp: {\n enabled: true,\n exposureLevel: 'custom',\n allowToolList: [\n 'cms_query_items',\n 'cms_create_item',\n 'cms_run_item_action',\n 'ai_classify',\n 'ai_judge',\n 'rag_answer',\n 'rag_search',\n 'search_query',\n 'webhooks_event_catalog',\n 'vxil_tool_search',\n ],\n rateLimits: { toolCallsPerMin: 60 },\n branding: {\n serverName: 'Agent Desk',\n serverInstructions:\n 'Answer from the knowledge base and cite it. Classify a ticket before replying. '\n + 'Never create or modify a record without the user confirming it first.',\n },\n // Config-declared prompts the agent can pull instead of you pasting one.\n prompts: {\n triage: {\n description: 'Triage an inbound ticket end to end.',\n template:\n 'Classify ticket {{ticket_id}} with ai_classify, then use rag_answer to draft a reply '\n + 'grounded in the knowledge base. Show me the draft and its citations before writing anything.',\n arguments: [{ name: 'ticket_id', description: 'the cms item id', required: true }],\n },\n },\n },\n\n // Enabled for ONE reason in this blueprint: the event CATALOG \u2014 the\n // machine-readable list of everything this backend can emit, which is how\n // an agent discovers what it is able to react to.\n webhooks: { enabled: true, maxSubscriptions: 5 },\n\n cms: {},\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n tickets: {\n singular: 'ticket',\n // ONE button per row. Pressing \"Draft reply\" runs the rag + judge step\n // once, as a human-initiated action \u2014 not on every write.\n actions: [{ key: 'draft_reply', label: 'Draft reply', fn: 'draft-reply' }],\n fields: {\n subject: { type: 'string', required: true, indexSlot: 's1' },\n requester: { type: 'string', indexSlot: 's2' },\n // written by the triage function, never by hand\n category: { type: 'string', indexSlot: 's3' }, // billing | bug | how_to | other\n state: { type: 'string', indexSlot: 's4' }, // open | drafted | closed\n draft_score: { type: 'int', indexSlot: 'n1' }, // the judge's score, 0\u201310\n created_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text' },\n draft: { type: 'text' }, // the last grounded draft reply\n },\n },\n\n kb: {\n singular: 'kb_article',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', unique: true, indexSlot: 's2' },\n topic: { type: 'string', indexSlot: 's3' },\n published_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // TRIAGE ON WRITE. Fires on every new ticket, re-fetches it (a hook\n // delivery carries ids, not the row), asks for a forced-label verdict, and\n // writes the label back. One model call, one field.\n 'triage-ticket': {\n entry: './functions/triage-ticket.ts',\n trigger: { kind: 'cmsHook', collection: 'tickets', event: 'beforeCreate' },\n scopes: ['cms:read', 'cms:write', 'ai:write'],\n egressAllow: [],\n },\n\n // DRAFT ON DEMAND. Invoked by the `draft_reply` record action: retrieve \u2192\n // ground \u2192 answer with citations, then score the draft against a rubric\n // before a human ever sees it.\n 'draft-reply': {\n entry: './functions/draft-reply.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'rag:write', 'ai:write'],\n egressAllow: [],\n },\n\n // CAPABILITY DISCOVERY. Returns the event names this backend can emit,\n // folded to the features it actually has on \u2014 the answer to an agent's\n // \"what can I react to here?\". No stored key: `webhooks:read` mints the\n // confined webhooks token that reads the event catalog, and `usage:read`\n // the confined control-plane token that lists the enabled features.\n 'agent-capabilities': {\n entry: './functions/agent-capabilities.ts',\n trigger: { kind: 'http' },\n scopes: ['webhooks:read', 'usage:read'],\n egressAllow: [],\n },\n },\n\n secrets: {\n // openai_key: { feature: 'ai', description: 'BYO model key \u2014 then set ai.defaultProvider' },\n },\n\n seed: {\n cms: [\n {\n collection: 'kb',\n items: [\n {\n title: 'How refunds work',\n slug: 'how-refunds-work',\n topic: 'billing',\n published_at: '2026-02-10T09:00:00Z',\n body:\n 'A refund is issued to the original payment method within 14 days of purchase. '\n + 'Ask the customer for the order id, confirm the purchase date, then issue the refund '\n + 'from the billing screen. Refunds are not available after 14 days.',\n },\n ],\n },\n ],\n },\n});\n",
19497
+ "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\nmkdir my-desk && cd my-desk\nvxil init --template agent-desk\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections + the three functions; writes vxil.types.ts\nvxil seed # one kb article ("How refunds work", a draft)\n```\n\n> **Plan note.** The three functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\nEverything runs on the deterministic **`mock`** model provider and the **`mock`** embedder, so the\nwalkthrough below is reproducible with no provider account and no spend. Going real is two switches,\nnot one (see [Going real](#going-real-the-model-and-the-embedder) at the end): the model, and the\nembedder the `kb` index was created with.\n\nThe capability probe needs no API key: `agent-capabilities` declares `webhooks:read` (the event\ncatalog) and `usage:read` (whose confined token also answers `GET /v1/features`), and reads both\nthrough its own scoped callback.\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: declare them, or create them\n\n`rag.defaultTemplate: \'support-answer\'` names a **prompt template**, a versioned row. You can\n**declare** it in `vxil.config.ts`, and `vxil push` converges it (a new version only when the text\nchanged \u2014 the funnel-saas blueprint\'s "Declared API state" shows the whole table):\n\n```ts\nai: {\n // \u2026the blueprint\'s ai block, plus:\n templates: [{\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},\n```\n\nOr create it over the API when you iterate on a prompt hourly and do not want a push per wording\nchange. Either way it must exist 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`. The project key quickstart minted carries these (`vxil env pull && set -a && . ./.env.local && set +a`, then `KEY=$VXIL_API_KEY`); the `vxil api` lines need no variable at all.\n\n**Reading the output comments.** Each `# 200 { "data": \u2026 }` comment shows the HTTP response. A raw\n`curl` call prints exactly that; a `vxil api` line prints only what is inside `data` (no status, no\nenvelope), ready for `jq` \u2014 so `jq .field`, not `jq .data.field`.\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\nA citation\'s `score` is a **rank** value (about 0.016\u20130.033 for the top hit), not a relevance\nmeasure \u2014 it orders the hits. To decide whether a chunk is close enough to the question, preview\nretrieval with `POST /v1/rag/search` and threshold each hit\'s `similarity` (cosine, \u22121..1) instead.\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": \u2026,\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\na scope this function can hold (functions never get `webhooks:write`):\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## Going real: the model and the embedder\n\nNothing in a response says that `mock` produced it, and swapping the model does **not** change the\nembedder: retrieval keeps ranking by a test hash, not by meaning, until you change both.\n\n1. **The model.** `vxil secrets set ai/openai_key`, then set `ai.defaultProvider: \'openai\'` and a real\n model in `ai.defaults.model`, and `vxil push`.\n2. **The embedder.** `vxil secrets set vector-search/openai_key`; set\n `features[\'vector-search\'].embed` to `{ provider: \'openai\', model: \'text-embedding-3-small\', apiKeyRef: \'secret:openai_key\', dimensions: 1536 }`\n and push. The `kb` index keeps the embedder it was **created** with, so re-send the create with the\n same width, which updates it in place:\n ```bash\n vxil api POST /v1/search/collections --data \'{"collection":"kb","dimensions":1536,"embed":{"provider":"openai","model":"text-embedding-3-small","apiKeyRef":"secret:openai_key"}}\'\n ```\n then re-ingest every article (the step 1 ingest per article, or publish them again for the sync to\n pick up), because the vectors already stored were made by `mock`. A different width needs a new\n collection name: a collection\'s width cannot change.\n\n**Pairs with:** the **ai-journal** blueprint (enrichment on write, and asking your own data questions)\nand the **helpdesk** blueprint (the same desk without the AI half).\n',
18883
19498
  "functions": {
18884
- "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\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\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\ntype Env = HttpFunctionEnvelope<{ all?: boolean }>;\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",
19499
+ "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// NO STORED KEY: the function's scoped callback covers both reads. The declared\n// `webhooks:read` mints a webhooks token confined to the inbound read routes\n// (the event catalog among them); `usage:read` mints the confined control-plane\n// token, which also answers `GET /v1/features` (that route needs no scope).\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\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\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\ntype Env = HttpFunctionEnvelope<{ all?: boolean }>;\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 hooks = env.scoped_jwts?.webhooks;\n const platform = env.scoped_jwts?.['control-plane'];\n if (!hooks || !platform) {\n return Response.json(\n { error: 'missing_scope', message: 'declare webhooks:read and usage:read on this function' },\n { status: 503 },\n );\n }\n\n const [catRes, featRes] = await Promise.all([\n fetch(`${base}/v1/webhooks/events/catalog`, { headers: { authorization: `Bearer ${hooks}` } }),\n fetch(`${base}/v1/features`, { headers: { authorization: `Bearer ${platform}` } }),\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",
18885
19500
  "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\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\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\ntype Env = HttpFunctionEnvelope<{\n collection?: string;\n item_id?: string;\n item?: { data?: { subject?: string; body?: string; category?: string } };\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 (guide ch. 8); 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",
18886
19501
  "triage-ticket.ts": "// triage-ticket.ts \u2014 CLASSIFY EVERY NEW TICKET (a vxil function).\n//\n// Trigger: cmsHook on `tickets`. A hook delivery carries ids, not the row\n// ({ event, collection, item_id }), so the function RE-FETCHES the ticket\n// rather than trusting inline fields \u2014 and delivery is at-least-once, so it\n// skips a ticket that already carries a category instead of re-billing a model\n// call on a redelivery.\n//\n// The one model call is a FORCED-LABEL verdict: `POST /v1/ai/classify` takes the\n// label set and returns exactly one of them (plus a confidence and a one-line\n// rationale). That is the difference between a classifier and a chat prompt \u2014\n// the answer cannot be a paragraph, a new label, or an apology.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\nconst LABELS = ['billing', 'bug', 'how_to', 'other'];\n\ntype Env = CmsHookFunctionEnvelope;\ninterface TicketData { subject?: string; body?: string; category?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (guide ch. 8); 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"
18887
19502
  }
@@ -18938,10 +19553,10 @@ export default defineConfig({
18938
19553
  "stripe_webhook"
18939
19554
  ],
18940
19555
  "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// \"Storefront\" \u2014 a single-seller e-commerce backend, declared end-to-end in ONE\n// typed file + four functions. There is no vxil \"e-commerce feature\": this is a\n// BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 the relational spine (catalog, variants, carts, orders, coupons, reviews)\n// \u2022 payments \u2192 a payments INTEGRATION with your own Stripe account (checkout\n// sessions + a store-credit ledger; vxil is never in the flow of funds)\n// \u2022 auth \u2192 shopper accounts, incl. anonymous GUEST checkout\n// \u2022 functions \u2192 the domain logic that isn't config: the checkout SAGA + pricing engine\n// \u2022 notifications \u2192 order receipts + abandoned-cart nudges\n// The fully-annotated deep version of this blueprint lives in examples/ecommerce.\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 draftPublish: true, // published rows are the live catalog; drafts are staging\n // Fail-safe: a verified end-user may only touch collections that declare an\n // ownerField (carts/orders/reviews) \u2014 never a tenant-wide collection.\n strictEndUserScope: true,\n hooks: {\n // \u2605 THE ORDER STATE MACHINE: reject any illegal status transition, atomically,\n // in the same write tx. (Cross-row work \u2014 the checkout saga \u2014 is a function.)\n order_transition: {\n collection: 'orders',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n \"item.status == before.status\" +\n \" || (before.status == 'pending' && (item.status == 'paid' || item.status == 'cancelled'))\" +\n \" || (before.status == 'paid' && (item.status == 'fulfilled' || item.status == 'refunded'))\" +\n \" || (before.status == 'fulfilled' && item.status == 'shipped')\",\n message: 'illegal order status transition',\n },\n // A review must carry a 1\u20135 rating.\n review_rating: {\n collection: 'reviews',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'item.rating >= 1 && item.rating <= 5',\n message: 'rating must be 1\u20135',\n },\n },\n },\n\n auth: {\n methods: { emailPassword: true, magicLink: true },\n anonymous: { enabled: true }, // guest checkout \u2192 claim the email later\n },\n\n payments: {\n provider: 'stripe',\n stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' },\n defaults: { currency: 'usd' },\n // The ledger is for STORE CREDIT / gift cards / loyalty (add-only grants +\n // balance-guarded consume) \u2014 populate productMap/tierMap per vxil.com/docs/guide/06-feature-catalog (payments).\n ledger: { productMap: {}, tierMap: {} },\n },\n\n notifications: { provider: 'mock', fromEmail: 'orders@storefront.example' },\n\n functions: { enabled: true },\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 products: {\n singular: 'product',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): the SHOPFRONT \u2014 published products read with\n // NO API key over GET /v1/cms/public/:tenantId/products, edge-cached. This is\n // the anonymous browse tier; the transactional collections below (carts,\n // orders, coupons) are NOT public and stay behind the owner-scoped authed lane.\n public: true,\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', required: true, indexSlot: 's2', unique: true },\n category: { type: 'string', indexSlot: 's3' },\n brand: { type: 'string', indexSlot: 's4' },\n price_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n rating_avg: { type: 'float', indexSlot: 'n2' }, // derived from reviews\n description: { type: 'text' },\n images: { type: 'json' },\n },\n },\n variants: {\n singular: 'variant',\n // PUBLIC DELIVERY (guide ch. 4, public delivery): the product-detail page needs the sellable\n // variants (price, availability, provider price_ref) \u2014 all shopfront-public\n // by design. `stock` here is a public availability signal; the oversell-safe\n // DECREMENT still happens only on the authed write lane inside checkout.ts.\n public: true,\n fields: {\n // `indexed` (guide ch. 4, indexed fields): the product page filters variants by\n // product (`{ product: productId }`) \u2014 equality only, so no slot is spent on it.\n product: { type: 'relation', relationTo: 'products', indexed: true },\n sku: { type: 'string', required: true, indexSlot: 's1', unique: true },\n title: { type: 'string', indexSlot: 's2' },\n price_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n // \u2605 THE OVERSELL-SAFE STOCK PATTERN: validation.min:0 makes a `$inc{stock:-qty}`\n // a single-statement conditional decrement (WHERE stock-qty >= 0) \u2014 exactly one\n // 200 under N concurrent buyers, 409 inc_out_of_bounds otherwise. No oversell.\n stock: { type: 'int', indexSlot: 'n2', validation: { min: 0 } },\n // The PROVIDER's price id for this variant (e.g. a Stripe Price) \u2014 the hosted\n // checkout-session charges by provider price ref, so mirror each sellable\n // variant into your Stripe catalog and store its id here (BYO provider).\n price_ref: { type: 'string' },\n options: { type: 'json' }, // { size, color, \u2026 } \u2014 not indexed\n },\n },\n carts: {\n singular: 'cart',\n ownerField: 'end_user', // owner-scoped: a shopper (or guest session) sees only their own carts\n fields: {\n status: { type: 'string', indexSlot: 's1' }, // open | ordered | abandoned\n currency: { type: 'string', indexSlot: 's2' },\n end_user: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n last_activity: { type: 'datetime', indexSlot: 't1' }, // abandoned-cart cron filter\n },\n },\n cart_items: {\n singular: 'cart_item',\n fields: {\n // checkout + price-cart read a cart's lines by `{ cart: cartId }` on every call\n cart: { type: 'relation', relationTo: 'carts', indexed: true },\n variant: { type: 'relation', relationTo: 'variants' },\n qty: { type: 'int', indexSlot: 'n1', validation: { min: 1 } },\n unit_price_cents: { type: 'int', indexSlot: 'n2' }, // price snapshot at add-to-cart\n // provider price-id snapshot at add-to-cart (from variants.price_ref) \u2014\n // checkout's line_items charge by THIS ref (payments checkout-sessions contract).\n price_ref: { type: 'string', required: true },\n },\n },\n orders: {\n singular: 'order',\n ownerField: 'end_user',\n fields: {\n number: { type: 'string', required: true, indexSlot: 's1', unique: true },\n // \u2605 EXACTLY-ONCE PLACEMENT: unique cart_ref \u2014 N racing checkouts of the same\n // cart \u2192 exactly one order insert succeeds (409 on the rest = idempotent).\n cart_ref: { type: 'string', required: true, indexSlot: 's2', unique: true },\n status: { type: 'string', indexSlot: 's3' }, // pending|paid|fulfilled|shipped|refunded|cancelled\n end_user: { type: 'string', indexSlot: 's4' }, // the owner (end-user) id\n total_cents: { type: 'int', indexSlot: 'n1' },\n placed_at: { type: 'datetime', indexSlot: 't1' },\n lines: { type: 'json' }, // [{ variant, qty, unit_price_cents, price_ref }] snapshot\n },\n },\n coupons: {\n singular: 'coupon',\n fields: {\n code: { type: 'string', required: true, indexSlot: 's1', unique: true },\n kind: { type: 'string', indexSlot: 's2' }, // percent | fixed\n value: { type: 'int', indexSlot: 'n1' }, // percent (1\u2013100) or cents\n max_uses: { type: 'int', indexSlot: 'n2' },\n },\n },\n // \u2605 One row per redemption. When you honor a coupon at capture, write the row\n // with a `guards[]` count-cap ON THE WRITE BODY \u2014 { lock: 'coupon:'+code,\n // guards: [{ filter: { code }, max: max_uses }] } (vxil.com/docs/api;\n // guard/guards ride the request, they are NOT config) \u2014 so a coupon can never\n // over-redeem under concurrency. (Not wired into checkout.ts \u2014 add it there.)\n coupon_redemptions: {\n singular: 'coupon_redemption',\n fields: {\n code: { type: 'string', required: true, indexSlot: 's1' },\n order: { type: 'relation', relationTo: 'orders' },\n },\n },\n // Owner-scoped write (a verified shopper posts their own), moderated via draft/publish.\n reviews: {\n singular: 'review',\n ownerField: 'end_user',\n fields: {\n product: { type: 'relation', relationTo: 'products' },\n rating: { type: 'int', required: true, indexSlot: 'n1', validation: { min: 1, max: 5 } },\n title: { type: 'string', indexSlot: 's1' },\n end_user: { type: 'string', indexSlot: 's2' }, // the owner (end-user) id\n body: { type: 'text' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions \u2500\u2500\n functions: {\n // The checkout SAGA \u2014 reserve inventory + create order + open payment, all-or-nothing.\n // Invoke checkout/price-cart SERVER-SIDE (your backend, server key): with\n // strictEndUserScope on, an end-user-mode invocation is correctly denied on the\n // shared collections they touch (cart_items/variants/coupons).\n checkout: {\n entry: './functions/checkout.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'payments:write'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // The pricing ENGINE \u2014 full JS (stacking/BOGO/tiers/coupons); cross-row cart math\n // is forbidden in hooks by design. Recompute server-side = anti-tamper.\n 'price-cart': {\n entry: './functions/price-cart.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read'],\n },\n // Settlement: on order \u2192 paid, fire the receipt + fulfillment webhook (side-effects).\n 'on-order-paid': {\n entry: './functions/on-order-paid.ts',\n trigger: { kind: 'cmsHook', collection: 'orders', event: 'beforeUpdate' },\n scopes: ['cms:read', 'notifications:send'],\n egressAllow: ['fulfillment.example.com'], // your 3PL/warehouse webhook\n },\n // Abandoned-cart nudge \u2014 hourly cron sweep over stale open carts.\n 'abandoned-cart': {\n entry: './functions/abandoned-cart.ts',\n trigger: { kind: 'cron', schedule: '0 * * * *' },\n // cms:write \u2014 the sweep PATCHes each nudged cart to `abandoned` (out of the filter)\n scopes: ['cms:read', 'cms:write', 'notifications:send'],\n },\n },\n\n secrets: {\n stripe_secret: { feature: 'payments', description: 'Stripe secret key (BYO)' },\n stripe_webhook: { feature: 'payments', description: 'Stripe webhook signing secret' },\n },\n\n seed: {\n cms: [\n {\n collection: 'products',\n items: [\n { title: 'Aeron Chair', slug: 'aeron-chair', category: 'furniture', brand: 'Herman Miller', price_cents: 149900, rating_avg: 4.8 },\n { title: 'Standing Desk', slug: 'standing-desk', category: 'furniture', brand: 'Uplift', price_cents: 59900, rating_avg: 4.6 },\n ],\n },\n { collection: 'coupons', items: [{ code: 'WELCOME10', kind: 'percent', value: 10, max_uses: 1000 }] },\n ],\n },\n});\n",
18941
- "readme": '# Storefront / E-commerce template\n\nA single-seller e-commerce backend \u2014 catalog, variants, owner-scoped carts, coupons, moderated\nreviews, and an exactly-once checkout \u2014 declared in one typed `vxil.config.ts` plus four functions.\nPayments run as a payments integration with **your own Stripe account**: vxil is never in the flow of funds.\n\n**What it provisions:**\n- `products` / `variants` \u2014 the catalog; `variants.stock` is the oversell-safe inventory counter;\n `variants.price_ref` holds the provider price id (e.g. a Stripe Price) the hosted checkout charges by.\n Both are **`public: true`** \u2014 the shopfront (grid + product-detail variants) reads keyless (see below);\n the transactional collections (`carts`/`orders`/`coupons`) are **not** public.\n- `carts` / `cart_items` \u2014 owner-scoped carts per shopper (guest checkout via anonymous auth);\n each line snapshots `unit_price_cents` + `price_ref` at add-to-cart.\n- `orders` \u2014 unique `number` + unique `cart_ref`, with a state-machine hook guarding status transitions.\n- `coupons` / `coupon_redemptions` \u2014 codes plus one row per redemption; cap redemption writes\n with a write-body `guards[]` under one `lock` (the lock + guard pattern, guide ch. 4 \u2014 shown in the config, not wired into `checkout.ts`).\n- `reviews` \u2014 owner-scoped, 1\u20135 rating enforced by a hook, moderated via draft/publish.\n- Features: `cms`, `auth`, `payments` (Stripe, BYO keys), `notifications`, `functions`; four functions \u2014\n `checkout` (the saga), `price-cart` (pricing engine), `on-order-paid` (receipt + fulfillment webhook),\n `abandoned-cart` (hourly cron nudge \u2014 one per abandonment: a nudged cart is marked `abandoned`).\n\n**Apply it:**\n\n```bash\nmkdir my-store && cd my-store\nvxil init --template storefront\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil secrets set stripe_secret && vxil secrets set stripe_webhook # your Stripe TEST-mode secret key + webhook signing secret (hidden prompts)\nvxil push # collections + hooks + the four functions; writes vxil.types.ts\nvxil seed # two demo products + the WELCOME10 coupon (drafts)\n```\n\n> **Plan note.** The four functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n**What to learn from this:**\n1. **The checkout saga** (`functions/checkout.ts`) \u2014 reserve inventory \u2192 create the order \u2192 open the\n payment, compensating on any failure. All-or-nothing across features, no cross-feature ACID needed.\n Invoke `checkout`/`price-cart` from YOUR backend (server key): with `strictEndUserScope` on, an\n end-user-mode invocation is correctly denied on the shared collections they touch\n (`cart_items`/`variants`/`coupons`) \u2014 inventory is tenant-wide by design.\n2. **Oversell-safety** \u2014 `variants.stock` with `validation: { min: 0 }` makes `PATCH { $inc: { stock: -qty } }`\n a single-statement conditional decrement: exactly one winner under N concurrent buyers.\n3. **Exactly-once placement** \u2014 `orders.cart_ref` is declared `unique: true` in the config (carried by\n `vxil push`, guide ch. 4, uniqueness), so N racing checkouts of one cart yield exactly one order \u2014 the rest see\n the `409 unique_violation` and `checkout.ts` answers `already_placed` (idempotent).\n **Two known gaps in the shipped `checkout.ts` \u2014 fix them before real traffic:**\n - **A repeat checkout leaks stock.** The function decrements `stock` *before* it creates the order,\n and the `409` (`already_placed`) branch returns without giving that stock back (only the other\n failure branch compensates). Each repeat or racing checkout of one cart costs one unit per line,\n for good. Fix: run the same `$inc: { stock: +qty }` compensation loop before answering\n `already_placed` \u2014 or, better, look the order up by `cart_ref` before reserving anything.\n - **Order numbers collide across carts.** `number` is `` `ORD-${cart_id.slice(0, 8)}` `` \u2014 the\n `itm_` prefix plus the first four id characters, which are a coarse timestamp, so carts created\n within about three days share a number. `orders.number` is `unique`, so a *second* cart\'s checkout\n also gets a `409` and is reported `already_placed` although no order exists. Fix: change that line\n to `` `ORD-${cart_id.slice(4)}` `` (the whole id) and push.\n\n```typescript\n// browse the live catalog with a server key (typed SDK;\n// `vx` = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! }))\nconst { items } = await vx.from(\'products\').query({\n filter: { $status: \'published\' },\n sort: \'-price_cents\',\n limit: 25,\n});\n```\n\n4. **The public shopfront \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). `products` and `variants` are\n `public: true`, so a static/JAMstack storefront renders the grid and product-detail pages with **no API\n key** \u2014 the edge forces `status = \'published\'` and caches the response for up to 60 seconds, so\n publish the seeded products first (step 2 below). Only the write path (cart, checkout, admin) needs a\n key; the transactional collections are never public.\n\n```ts\nimport { listCmsPublic } from \'https://vxil.com/sdk.mjs\';\n// the public grid \u2014 NO api key\nconst TENANT_ID = \'00000000-0000-0000-0000-000000000000\'; // yours: the `tenant:` line quickstart printed\nconst { items } = await listCmsPublic(TENANT_ID, \'products\', { sort: \'-price_cents\', limit: 24 });\n// a product\'s sellable variants (price, availability, provider price_ref)\nconst variants = await listCmsPublic(TENANT_ID, \'variants\', { filter: { product: productId } });\n```\n\n5. **Equality without spending a slot** \u2014 `variants.product` and `cart_items.cart` are only ever matched by\n equality (`{ product: productId }`, `{ cart: cartId }`), so they are marked `indexed: true` instead of\n taking one of the eight index slots: those filters are index-served on the authed list and the public\n lane alike (up to 4 per collection; guide ch. 4, indexed fields).\n\n## First order, end to end\n\nThe seed has products but no variants, carts or price ids, so one order needs four short steps. Use\nStripe **test mode** throughout; `price_\u2026` below is a Price id from your Stripe test dashboard.\n\n1. **Let Stripe reach your backend.** In Stripe\'s dashboard, add a webhook endpoint\n `https://api.vxil.com/v1/internal/payments/webhook/stripe/<tenant id>` (the `tenant:` line quickstart\n printed), and store that endpoint\'s signing secret as `stripe_webhook` (above).\n2. **A buyer, a sellable variant and a cart** (server key; `vxil api` uses the bound project\'s). The\n cart\'s `end_user` must be a real end-user of the project, or the receipt in step 4 has nobody to go to:\n ```bash\n vxil api POST /v1/users --data \'{"id":"usr_demo","email":"buyer@example.com"}\'\n PRODUCT=$(vxil api GET \'/v1/cms/items/products?filter={"slug":"aeron-chair"}\' | jq -r \'.items[0].item_id\')\n vxil api POST /v1/cms/items/products/$PRODUCT/publish\n VARIANT=$(vxil api POST /v1/cms/items/variants --data \'{"data":{"product":"\'$PRODUCT\'","sku":"AERON-B","title":"Aeron, size B","price_cents":149900,"stock":5,"price_ref":"price_\u2026"},"status":"published"}\' | jq -r .item_id)\n CART=$(vxil api POST /v1/cms/items/carts --data \'{"data":{"status":"open","currency":"usd","end_user":"usr_demo"},"status":"published"}\' | jq -r .item_id)\n vxil api POST /v1/cms/items/cart_items --data \'{"data":{"cart":"\'$CART\'","variant":"\'$VARIANT\'","qty":1,"unit_price_cents":149900,"price_ref":"price_\u2026"},"status":"published"}\'\n ```\n3. **Check out** (from your server, as here, never from the browser):\n ```bash\n vxil functions invoke checkout --data \'{"cart_id":"\'$CART\'"}\'\n # \u2192 { "order_id": "itm_\u2026", "number": "\u2026", "checkout_url": "https://checkout.stripe.com/\u2026" }\n vxil functions invoke checkout --data \'{"cart_id":"\'$CART\'"}\'\n # \u2192 { "status": "already_placed", "number": "\u2026" } \u2014 the same cart never makes a second order\n ```\n The variant\'s `stock` is now 4 after the first call \u2014 and **3** after the repeat, because of the\n stock leak described under What to learn #3 (apply that fix and the repeat leaves it at 4). Open `checkout_url` and pay with Stripe\'s test card `4242 4242 4242 4242`\n (any future date, any CVC).\n4. **Mark the order paid.** The payment folds into the payments integration as\n `payments.charge.succeeded`, but this blueprint does **not** ship the handler that flips the order:\n that is yours to write (a function subscribed to the `payments.` prefix, or your backend after the\n hosted checkout returns). To watch the rest of the flow, flip it by hand:\n ```bash\n vxil api PATCH /v1/cms/items/orders/<order_id> --data \'{"data":{"status":"paid"}}\'\n ```\n `on-order-paid` then e-mails the receipt to `usr_demo` (the `mock` notifications provider records it \u2014\n see it with `vxil api GET /v1/notifications/deliveries`; the function ignores send errors, so if the\n buyer from step 2 is missing you simply see no delivery) and posts the\n fulfilment webhook to `fulfillment.example.com`, which you replace with your warehouse\'s host in the\n function\'s `egressAllow` and code. The state-machine hook refuses any other jump (`pending \u2192 shipped`\n is a clean `422`).\n\n**Reader UI (optional):** drop **`@vxil/react/comments`** (`npm i @vxil/react @vxil/sdk`) onto a product page\nfor the `reviews` thread + a file uploader (enable the `comments` feature) \u2014 a pure client-side component\nover the shipped API. It installs via npm into a bundled app (no served `.mjs`).\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (**public delivery**), vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (`$inc`, `lock`/`guards[]` on the item write routes),\nvxil.com/docs/guide/06-feature-catalog (payments),\nvxil.com/docs/guide/08-running-your-code-functions, and the **catalog** blueprint (a content-only\npublic product grid).\n',
19556
+ "readme": '# Storefront / E-commerce template\n\nA single-seller e-commerce backend \u2014 catalog, variants, owner-scoped carts, coupons, moderated\nreviews, and an exactly-once checkout \u2014 declared in one typed `vxil.config.ts` plus four functions.\nPayments run as a payments integration with **your own Stripe account**: vxil is never in the flow of funds.\n\n**What it provisions:**\n- `products` / `variants` \u2014 the catalog; `variants.stock` is the oversell-safe inventory counter;\n `variants.price_ref` holds the provider price id (e.g. a Stripe Price) the hosted checkout charges by.\n Both are **`public: true`** \u2014 the shopfront (grid + product-detail variants) reads keyless (see below);\n the transactional collections (`carts`/`orders`/`coupons`) are **not** public.\n- `carts` / `cart_items` \u2014 owner-scoped carts per shopper (guest checkout via anonymous auth);\n each line snapshots `unit_price_cents` + `price_ref` at add-to-cart.\n- `orders` \u2014 unique `number` + unique `cart_ref`, with a state-machine hook guarding status transitions.\n- `coupons` / `coupon_redemptions` \u2014 codes plus one row per redemption; cap redemption writes\n with a write-body `guards[]` under one `lock` (the lock + guard pattern, guide ch. 4 \u2014 shown in the config, not wired into `checkout.ts`).\n- `reviews` \u2014 owner-scoped, 1\u20135 rating enforced by a hook, moderated via draft/publish.\n- Features: `cms`, `auth`, `payments` (Stripe, BYO keys), `notifications`, `functions`; four functions \u2014\n `checkout` (the saga), `price-cart` (pricing engine), `on-order-paid` (receipt + fulfillment webhook),\n `abandoned-cart` (hourly cron nudge \u2014 one per abandonment: a nudged cart is marked `abandoned`).\n\n**Apply it:**\n\n```bash\nmkdir my-store && cd my-store\nvxil init --template storefront\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil secrets set stripe_secret && vxil secrets set stripe_webhook # your Stripe TEST-mode secret key + webhook signing secret (hidden prompts)\nvxil push # collections + hooks + the four functions; writes vxil.types.ts\nvxil seed # two demo products + the WELCOME10 coupon (drafts)\n```\n\n> **Plan note.** The four functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n**What to learn from this:**\n1. **The checkout saga** (`functions/checkout.ts`) \u2014 reserve inventory \u2192 create the order \u2192 open the\n payment, compensating on any failure. All-or-nothing across features, no cross-feature ACID needed.\n Invoke `checkout`/`price-cart` from YOUR backend (server key): with `strictEndUserScope` on, an\n end-user-mode invocation is correctly denied on the shared collections they touch\n (`cart_items`/`variants`/`coupons`) \u2014 inventory is tenant-wide by design.\n2. **Oversell-safety** \u2014 `variants.stock` with `validation: { min: 0 }` makes `PATCH { $inc: { stock: -qty } }`\n a single-statement conditional decrement: exactly one winner under N concurrent buyers.\n3. **Exactly-once placement** \u2014 `orders.cart_ref` is declared `unique: true` in the config (carried by\n `vxil push`, guide ch. 4, uniqueness), so N racing checkouts of one cart yield exactly one order \u2014 the rest see\n the `409 unique_violation` and `checkout.ts` answers `already_placed` (idempotent).\n On that `409` the function first puts back the stock it reserved, then looks the order up by\n `cart_ref`: only when an order for this cart exists does it answer `already_placed` (with that\n order\'s id and number); any other conflict is `409 order_conflict`. The order `number` is\n `ORD-` plus the whole cart id, so two carts never share one.\n\n```typescript\n// browse the live catalog with a server key (typed SDK;\n// `vx` = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! }))\nconst { items } = await vx.from(\'products\').query({\n filter: { $status: \'published\' },\n sort: \'-price_cents\',\n limit: 25,\n});\n```\n\n4. **The public shopfront \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). `products` and `variants` are\n `public: true`, so a static/JAMstack storefront renders the grid and product-detail pages with **no API\n key** \u2014 the edge forces `status = \'published\'` and caches the response for up to 60 seconds, so\n publish the seeded products first (step 2 below). Only the write path (cart, checkout, admin) needs a\n key; the transactional collections are never public.\n\n```ts\nimport { listCmsPublic } from \'https://vxil.com/sdk.mjs\';\n// the public grid \u2014 NO api key\nconst TENANT_ID = \'00000000-0000-0000-0000-000000000000\'; // yours: the `tenant:` line quickstart printed\nconst { items } = await listCmsPublic(TENANT_ID, \'products\', { sort: \'-price_cents\', limit: 24 });\n// a product\'s sellable variants (price, availability, provider price_ref)\nconst variants = await listCmsPublic(TENANT_ID, \'variants\', { filter: { product: productId } });\n```\n\n5. **Equality without spending a slot** \u2014 `variants.product` and `cart_items.cart` are only ever matched by\n equality (`{ product: productId }`, `{ cart: cartId }`), so they are marked `indexed: true` instead of\n taking one of the eight index slots: those filters are index-served on the authed list and the public\n lane alike (up to 4 per collection; guide ch. 4, indexed fields).\n\n## First order, end to end\n\nThe seed has products but no variants, carts or price ids, so one order needs four short steps. Use\nStripe **test mode** throughout; `price_\u2026` below is a Price id from your Stripe test dashboard.\n\n1. **Let Stripe reach your backend.** In Stripe\'s dashboard, add a webhook endpoint\n `https://api.vxil.com/v1/internal/payments/webhook/stripe/<tenant id>` (the `tenant:` line quickstart\n printed), and store that endpoint\'s signing secret as `stripe_webhook` (above).\n2. **A buyer, a sellable variant and a cart** (server key; `vxil api` uses the bound project\'s). The\n cart\'s `end_user` must be a real end-user of the project, or the receipt in step 4 has nobody to go to:\n ```bash\n vxil api POST /v1/users --data \'{"id":"usr_demo","email":"buyer@example.com"}\'\n PRODUCT=$(vxil api GET \'/v1/cms/items/products?filter={"slug":"aeron-chair"}\' | jq -r \'.items[0].item_id\')\n vxil api POST /v1/cms/items/products/$PRODUCT/publish\n VARIANT=$(vxil api POST /v1/cms/items/variants --data \'{"data":{"product":"\'$PRODUCT\'","sku":"AERON-B","title":"Aeron, size B","price_cents":149900,"stock":5,"price_ref":"price_\u2026"},"status":"published"}\' | jq -r .item_id)\n CART=$(vxil api POST /v1/cms/items/carts --data \'{"data":{"status":"open","currency":"usd","end_user":"usr_demo"},"status":"published"}\' | jq -r .item_id)\n vxil api POST /v1/cms/items/cart_items --data \'{"data":{"cart":"\'$CART\'","variant":"\'$VARIANT\'","qty":1,"unit_price_cents":149900,"price_ref":"price_\u2026"},"status":"published"}\'\n ```\n3. **Check out** (from your server, as here, never from the browser):\n ```bash\n vxil functions invoke checkout --data \'{"cart_id":"\'$CART\'"}\'\n # \u2192 { "order_id": "itm_\u2026", "number": "\u2026", "checkout_url": "https://checkout.stripe.com/\u2026" }\n vxil functions invoke checkout --data \'{"cart_id":"\'$CART\'"}\'\n # \u2192 { "status": "already_placed", "order_id": "itm_\u2026", "number": "\u2026" } \u2014 the same cart never makes a second order\n ```\n The variant\'s `stock` is now 4 after the first call, and still 4 after the repeat (the repeat puts\n its reservation back). Open `checkout_url` and pay with Stripe\'s test card `4242 4242 4242 4242`\n (any future date, any CVC).\n4. **Mark the order paid.** The payment folds into the payments integration as\n `payments.charge.succeeded`, but this blueprint does **not** ship the handler that flips the order:\n that is yours to write (a function subscribed to the `payments.` prefix, or your backend after the\n hosted checkout returns). To watch the rest of the flow, flip it by hand:\n ```bash\n vxil api PATCH /v1/cms/items/orders/<order_id> --data \'{"data":{"status":"paid"}}\'\n ```\n `on-order-paid` then e-mails the receipt to `usr_demo` (the `mock` notifications provider records it \u2014\n see it with `vxil api GET /v1/notifications/deliveries`; the function ignores send errors, so if the\n buyer from step 2 is missing you simply see no delivery) and posts the\n fulfilment webhook to `fulfillment.example.com`, which you replace with your warehouse\'s host in the\n function\'s `egressAllow` and code. The state-machine hook refuses any other jump (`pending \u2192 shipped`\n is a clean `422`).\n\n**Reader UI (optional):** drop **`@vxil/react/comments`** (`npm i @vxil/react @vxil/sdk`) onto a product page\nfor the `reviews` thread + a file uploader (enable the `comments` feature) \u2014 a pure client-side component\nover the shipped API. It installs via npm into a bundled app (no served `.mjs`).\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (**public delivery**), vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (`$inc`, `lock`/`guards[]` on the item write routes),\nvxil.com/docs/guide/06-feature-catalog (payments),\nvxil.com/docs/guide/08-running-your-code-functions, and the **catalog** blueprint (a content-only\npublic product grid).\n',
18942
19557
  "functions": {
18943
19558
  "abandoned-cart.ts": "// abandoned-cart.ts \u2014 RETENTION CRON (a vxil function).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep open carts that went stale (last_activity\n// older than 1h) using the slot-indexed range filter, and nudge the shopper. This is the\n// jobs-cron pattern \u2014 no new primitive, just a scheduled function.\n//\n// ONE nudge per abandonment: after the send, the cart is marked `abandoned`, which takes\n// it out of the `status: 'open'` filter \u2014 so the next tick reads the NEXT stale carts\n// instead of the same 100 again (and a shopper is not nudged every hour forever). Your\n// app sets `status` back to `open` (with a fresh `last_activity`) when the shopper\n// touches the cart again; a later abandonment is nudged again. The Idempotency-Key\n// (cart id + last_activity) keeps a redelivered tick, or a failed mark, from\n// sending the same nudge twice.\n\n// cron-walk: drains-filter \u2014 a nudged cart is marked `abandoned` and leaves the `status: 'open'` filter.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Cart { status: string; last_activity: string; end_user?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // carts still `open` whose last_activity is > 1h ago (t1 range filter, index-served)\n const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString();\n const filter = enc({ status: 'open', last_activity: { $lt: cutoff } });\n const res = await fetch(`${base}/v1/cms/items/carts?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ error: `query ${res.status}` }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: Cart }[] } };\n const carts = body.data?.items ?? [];\n\n // POST /v1/notifications/send is { user_id, template, data } \u2014 `transactional` is the\n // shipped generic template (requires data.subject + data.paragraph, guide ch. 6, notifications).\n let nudged = 0;\n let markFailed = 0;\n for (const c of carts) {\n if (!notif || !c.data.end_user) continue;\n const sent = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n 'idempotency-key': `abandoned-cart:${c.item_id}:${c.data.last_activity}`,\n },\n body: JSON.stringify({\n user_id: c.data.end_user,\n template: 'transactional',\n data: {\n subject: 'You left items in your cart',\n paragraph: `Your cart (${c.item_id}) is still waiting \u2014 come back and finish checkout any time.`,\n },\n }),\n }).then((r) => r.ok).catch(() => false);\n if (!sent) continue; // left open: the next tick tries again (same Idempotency-Key)\n nudged++;\n // Out of the filter: the next tick reads the next stale carts, not these again.\n // The mark needs `cms:write` (declared in vxil.config.ts). A refused mark is\n // COUNTED, never swallowed: the cart stays open, so the next tick re-reads it\n // (the Idempotency-Key keeps the nudge from going out twice) \u2014 and a run that\n // reports `mark_failed > 0` answers 500, so it is SEEN (`functions.run.failed`).\n const marked = await fetch(`${base}/v1/cms/items/carts/${c.item_id}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { status: 'abandoned' } }),\n }).then((r) => r.ok).catch(() => false);\n if (!marked) markFailed++;\n }\n return Response.json({ scanned: carts.length, nudged, mark_failed: markFailed }, { status: markFailed > 0 ? 500 : 200 });\n },\n};\n\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n",
18944
- "checkout.ts": "// checkout.ts \u2014 THE CHECKOUT SAGA (a vxil function).\n//\n// The hard part of e-commerce: \"reserve N SKUs + capture payment + create the order,\n// all-or-nothing\" \u2014 which is the deliberately-REJECTED cross-feature-ACID case. The\n// doctrinal (and incumbent-identical) answer is a reserve\u2192settle\u2192reverse SAGA, and it\n// is exactly-once under any concurrency. Shopify+Stripe do the same thing (Stripe is a\n// physically separate system reconciled by webhook); nothing here is a platform gap.\n//\n// Invoke it SERVER-SIDE (your backend POSTs /v1/fn/checkout with a server key):\n// under cms.strictEndUserScope an end-user-mode invocation is correctly denied on\n// the shared collections this saga touches (cart_items/variants) \u2014 inventory is a\n// tenant-wide surface, so the reserve step is server work by design.\n//\n// Steps:\n// 1. read the cart (owner + currency) + its lines (cms:read)\n// 2. RESERVE each line: PATCH variant {$inc:{stock:-qty}} \u2014 validation.min:0 makes it a\n// single-statement oversell-safe decrement (409 inc_out_of_bounds if insufficient).\n// On any failure \u2192 COMPENSATE (re-$inc the ones already reserved) \u2192 409 out_of_stock.\n// 3. create the ORDER with a unique cart_ref \u2192 EXACTLY-ONCE (409 on a racing duplicate).\n// 4. open a payments checkout-session (mode:payment, Idempotency-Key = order number).\n// 5. return { order_id, checkout_url }. Capture completes async \u2192 functions/on-order-paid.ts.\n// (Not shipped here: if payment never completes, schedule a jobs `deliver_after`\n// release that re-$inc's the reserve.)\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload`\ntype Env = HttpFunctionEnvelope<{ cart_id?: string; success_url?: string; cancel_url?: string }>;\ninterface Line { variant: string; qty: number; unit_price_cents: number; price_ref: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const pay = env.scoped_jwts?.payments;\n if (!cms || !pay) return json({ error: 'missing cms/payments scope' }, 403);\n const { cart_id, success_url, cancel_url } = env.payload ?? {};\n if (!cart_id) return json({ error: 'cart_id required' }, 400);\n\n const H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n\n // 1. read the cart (its end_user owner + currency), then its lines\n const cartRes = await fetch(`${base}/v1/cms/items/carts/${cart_id}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!cartRes.ok) return json({ error: 'cart_not_found' }, 404);\n const cart = ((await cartRes.json()) as { data?: { data?: { end_user?: string; currency?: string } } }).data?.data ?? {};\n const shopper = env.end_user?.id ?? cart.end_user;\n if (!shopper) return json({ error: 'cart has no owner (end_user)' }, 400);\n const lines = await get<Line>(`${base}/v1/cms/items/cart_items?filter=${enc({ cart: cart_id })}&limit=100`, cms);\n if (lines.length === 0) return json({ error: 'empty cart' }, 400);\n\n // 2. RESERVE inventory line-by-line (oversell-safe $inc). Track for compensation.\n const reserved: Line[] = [];\n for (const ln of lines) {\n const r = await fetch(`${base}/v1/cms/items/variants/${ln.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: -ln.qty } }),\n });\n if (!r.ok) {\n // compensate everything reserved so far, then fail cleanly\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'out_of_stock', variant: ln.variant }, 409);\n }\n reserved.push(ln);\n }\n\n // 3. create the ORDER \u2014 unique cart_ref makes placement exactly-once under concurrency.\n const total = lines.reduce((s, l) => s + l.unit_price_cents * l.qty, 0);\n const number = `ORD-${cart_id.slice(0, 8)}`;\n const orderRes = await fetch(`${base}/v1/cms/items/orders`, {\n method: 'POST', headers: H(cms),\n body: JSON.stringify({\n data: {\n number, cart_ref: cart_id, status: 'pending', end_user: shopper,\n total_cents: total, placed_at: new Date().toISOString(), lines,\n },\n }),\n });\n if (orderRes.status === 409) {\n // a concurrent checkout already placed this cart \u2192 idempotent: report it placed\n return json({ status: 'already_placed', number }, 200);\n }\n if (!orderRes.ok) {\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'order_create_failed' }, 502);\n }\n const order = (await orderRes.json()) as { data?: { item_id?: string } };\n\n // 4. open the hosted payment (one-time). Idempotency-Key = order number \u21D2 safe to retry.\n // The documented checkout-sessions contract (guide ch. 6, payments): 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",
19559
+ "checkout.ts": "// checkout.ts \u2014 THE CHECKOUT SAGA (a vxil function).\n//\n// The hard part of e-commerce: \"reserve N SKUs + capture payment + create the order,\n// all-or-nothing\" \u2014 which is the deliberately-REJECTED cross-feature-ACID case. The\n// doctrinal (and incumbent-identical) answer is a reserve\u2192settle\u2192reverse SAGA, and it\n// is exactly-once under any concurrency. Shopify+Stripe do the same thing (Stripe is a\n// physically separate system reconciled by webhook); nothing here is a platform gap.\n//\n// Invoke it SERVER-SIDE (your backend POSTs /v1/fn/checkout with a server key):\n// under cms.strictEndUserScope an end-user-mode invocation is correctly denied on\n// the shared collections this saga touches (cart_items/variants) \u2014 inventory is a\n// tenant-wide surface, so the reserve step is server work by design.\n//\n// Steps:\n// 1. read the cart (owner + currency) + its lines (cms:read)\n// 2. RESERVE each line: PATCH variant {$inc:{stock:-qty}} \u2014 validation.min:0 makes it a\n// single-statement oversell-safe decrement (409 inc_out_of_bounds if insufficient).\n// On any failure \u2192 COMPENSATE (re-$inc the ones already reserved) \u2192 409 out_of_stock.\n// 3. create the ORDER with a unique cart_ref \u2192 EXACTLY-ONCE (409 on a racing duplicate).\n// 4. open a payments checkout-session (mode:payment, Idempotency-Key = order number).\n// 5. return { order_id, checkout_url }. Capture completes async \u2192 functions/on-order-paid.ts.\n// (Not shipped here: if payment never completes, schedule a jobs `deliver_after`\n// release that re-$inc's the reserve.)\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload`\ntype Env = HttpFunctionEnvelope<{ cart_id?: string; success_url?: string; cancel_url?: string }>;\ninterface Line { variant: string; qty: number; unit_price_cents: number; price_ref: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const pay = env.scoped_jwts?.payments;\n if (!cms || !pay) return json({ error: 'missing cms/payments scope' }, 403);\n const { cart_id, success_url, cancel_url } = env.payload ?? {};\n if (!cart_id) return json({ error: 'cart_id required' }, 400);\n\n const H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n\n // 1. read the cart (its end_user owner + currency), then its lines\n const cartRes = await fetch(`${base}/v1/cms/items/carts/${cart_id}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!cartRes.ok) return json({ error: 'cart_not_found' }, 404);\n const cart = ((await cartRes.json()) as { data?: { data?: { end_user?: string; currency?: string } } }).data?.data ?? {};\n const shopper = env.end_user?.id ?? cart.end_user;\n if (!shopper) return json({ error: 'cart has no owner (end_user)' }, 400);\n const lines = await get<Line>(`${base}/v1/cms/items/cart_items?filter=${enc({ cart: cart_id })}&limit=100`, cms);\n if (lines.length === 0) return json({ error: 'empty cart' }, 400);\n\n // 2. RESERVE inventory line-by-line (oversell-safe $inc). Track for compensation.\n const reserved: Line[] = [];\n for (const ln of lines) {\n const r = await fetch(`${base}/v1/cms/items/variants/${ln.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: -ln.qty } }),\n });\n if (!r.ok) {\n // compensate everything reserved so far, then fail cleanly\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'out_of_stock', variant: ln.variant }, 409);\n }\n reserved.push(ln);\n }\n\n // 3. create the ORDER \u2014 unique cart_ref makes placement exactly-once under concurrency.\n const total = lines.reduce((s, l) => s + l.unit_price_cents * l.qty, 0);\n // The WHOLE cart id (after `itm_`): its first characters are only a coarse\n // timestamp, and `orders.number` is unique, so a prefix collides across carts.\n const number = `ORD-${cart_id.slice(4)}`;\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 const release = () => 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 if (orderRes.status === 409) {\n // A unique field refused the insert. Give this attempt's reservation back\n // (the order that won holds its own), then confirm an order for THIS cart\n // exists before reporting it placed \u2014 idempotent, and no stock leak.\n await release();\n const [existing] = await get<{ id: string; number: string }>(\n `${base}/v1/cms/items/orders?filter=${enc({ cart_ref: cart_id })}&limit=1`, cms);\n if (existing) return json({ status: 'already_placed', order_id: existing.id, number: existing.number }, 200);\n return json({ error: 'order_conflict' }, 409);\n }\n if (!orderRes.ok) {\n await release();\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 (guide ch. 6, payments): 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",
18945
19560
  "on-order-paid.ts": "// on-order-paid.ts \u2014 SETTLEMENT SIDE-EFFECTS (a vxil function).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.updated for `orders`. When the order flips to\n// `paid` \u2014 YOUR payment-success handler PATCHes it (e.g. a function subscribed to the\n// payments `payments.charge.succeeded` event via a webhooks-out subscription on the\n// `payments.` prefix, or your backend after the hosted checkout returns); the\n// order_transition hook validates the flip \u2014 fan out the side-effects:\n// email the receipt (notifications) and POST the fulfillment webhook to the tenant's\n// 3PL/warehouse over the egress allowlist. Delivery is at-least-once with retry/DLQ \u2014\n// identical semantics to Shopify Flow / a Stripe webhook fan-out.\n//\n// The cms-hook payload is { event, collection, item_id } \u2014 NOT the row \u2014 so the\n// function RE-FETCHES the order by id (through the edge, tenant-scoped). notifications:send\n// is a legitimate function scope (allowed by the deploy; https://vxil.com/docs/guide/08-running-your-code-functions).\n//\n// (Inventory was already reserved atomically at checkout, so there is no decrement here \u2014\n// the reservation simply becomes permanent. A payment FAILURE path compensates instead.)\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CmsHookFunctionEnvelope;\ninterface OrderData { number?: string; status?: string; total_cents?: number; end_user?: string }\ninterface Item { data?: { data?: OrderData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (guide ch. 8); 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",
18946
19561
  "price-cart.ts": "// price-cart.ts \u2014 THE PRICING ENGINE (a vxil function).\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 (guide ch. 7), 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).\n//\n// It reads the cart lines + coupon (cms:read) and returns the priced cart. Like checkout,\n// invoke it SERVER-SIDE: under cms.strictEndUserScope the shared collections it reads\n// (cart_items/coupons) are correctly denied to an end-user-mode invocation. checkout.ts\n// recomputes its total from the same server-held snapshots \u2014 never trust a client total;\n// to honor promotions at capture time, apply this function's output there the same way.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\n// the caller's JSON body rides the invocation envelope under `payload`\ntype Env = HttpFunctionEnvelope<{ cart_id?: string; coupon_code?: string }>;\ninterface Line { variant: string; qty: number; unit_price_cents: number }\ninterface Coupon { code: string; kind: string; value: number; max_uses: number }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const { cart_id, coupon_code } = env.payload ?? {};\n if (!cart_id) return Response.json({ error: 'cart_id required' }, { status: 400 });\n\n const lines = await get<Line>(`${base}/v1/cms/items/cart_items?filter=${enc({ cart: cart_id })}&limit=100`, cms);\n\n // subtotal (cross-row sum \u2014 the thing a hook can't do)\n const subtotal = lines.reduce((s, l) => s + l.unit_price_cents * l.qty, 0);\n\n // \u2500\u2500 arbitrary promotion rules, plain JS \u2500\u2500\n let discount = 0;\n const applied: string[] = [];\n\n // BOGO on any 2+ identical lines: cheapest unit free per pair\n for (const l of lines) {\n if (l.qty >= 2) { discount += Math.floor(l.qty / 2) * l.unit_price_cents; applied.push('bogo'); }\n }\n\n // tiered cart threshold: 5% over $100, 10% over $250\n if (subtotal >= 25000) { discount += Math.round(subtotal * 0.10); applied.push('tier-10'); }\n else if (subtotal >= 10000) { discount += Math.round(subtotal * 0.05); applied.push('tier-5'); }\n\n // coupon (percent or fixed) \u2014 stacks on top, capped so total never goes below 0\n if (coupon_code) {\n const [c] = await get<Coupon>(`${base}/v1/cms/items/coupons?filter=${enc({ code: coupon_code })}&limit=1`, cms);\n if (c) {\n discount += c.kind === 'percent' ? Math.round(subtotal * (c.value / 100)) : c.value;\n applied.push(`coupon:${c.code}`);\n }\n }\n\n const total = Math.max(0, subtotal - discount);\n return Response.json({ subtotal_cents: subtotal, discount_cents: subtotal - total, total_cents: total, applied });\n },\n};\n\nasync function get<T>(url: string, jwt: string): Promise<T[]> {\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: T }[] } };\n return (body.data?.items ?? []).map((i) => ({ id: i.item_id, ...i.data } as T));\n}\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n"
18947
19562
  }
@@ -19108,8 +19723,8 @@ export default defineConfig({
19108
19723
  ],
19109
19724
  "hasFunctions": true,
19110
19725
  "byoKeys": [],
19111
- "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// \"Helpdesk\" \u2014 a support-ticketing backend (tickets + threaded messages),\n// declared end-to-end in ONE typed file. A BLUEPRINT composing shipped blocks \u2014\n// \u2022 cms \u2192 tickets \u2192 ticket_messages (resolved by relation), with a\n// Lane-A STATE-MACHINE hook guarding ticket.status\n// \u2022 auth \u2192 requesters sign in as end-users; tickets are owner-scoped\n// \u2022 notifications \u2192 the acknowledgement message on every new ticket\n// \u2022 functions \u2192 the async side: the ack cms-hook + the hourly SLA sweep\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 // Fail-safe owner scoping: a signed-in requester may only touch collections\n // with an `ownerField`. `tickets` has one; `ticket_messages` does not, so\n // without this flag every requester could read every thread. With it, the\n // thread is server-only for end users (your agent backend writes and reads\n // it) \u2014 give it an owner field if requesters should post replies themselves.\n strictEndUserScope: true,\n hooks: {\n // The TICKET STATE MACHINE: reject any illegal status transition,\n // atomically, in the same write (`closed` is terminal). Async\n // side-effects (the ack send, the SLA sweep) live in functions \u2014\n // a Lane-A hook is a pure expression and can't do I/O.\n ticket_transition: {\n collection: 'tickets',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.status == before.status' +\n \" || (before.status == 'open' && (item.status == 'pending' || item.status == 'resolved'))\" +\n \" || (before.status == 'pending' && (item.status == 'open' || item.status == 'resolved'))\" +\n \" || (before.status == 'resolved' && item.status == 'closed')\",\n message: 'illegal ticket status transition',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // requesters are verified end-users\n notifications: { provider: 'mock', fromEmail: 'support@helpdesk.example' },\n functions: { enabled: true },\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 tickets: {\n singular: 'ticket',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a signed-in requester\n // reads/edits ONLY their own tickets \u2014 one declarative flag, a no-op for\n // server callers (your agent backend sees the whole queue).\n ownerField: 'requester',\n fields: {\n subject: { type: 'string', required: true, indexSlot: 's1' },\n status: { type: 'string', indexSlot: 's2' }, // open | pending | resolved | closed (hook-guarded)\n priority: { type: 'string', indexSlot: 's3' }, // low | normal | high | urgent\n requester: { type: 'string', indexSlot: 's4' }, // the owner (end-user) id\n opened_at: { type: 'datetime', indexSlot: 't1' },\n sla_due: { type: 'datetime', indexSlot: 't2' }, // the sla-sweep cron's range filter\n body: { type: 'text' },\n },\n },\n ticket_messages: {\n singular: 'ticket_message',\n fields: {\n ticket: { type: 'relation', relationTo: 'tickets', indexSlot: 's1' },\n author: { type: 'string', indexSlot: 's2' }, // requester or agent id\n body: { type: 'text' },\n sent_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions \u2500\u2500\n functions: {\n // Acknowledgement: on ticket CREATE, RE-FETCH the ticket by id (the cms-hook\n // payload carries only { event, collection, item_id }) and send the requester\n // an acknowledgement through notifications.\n 'on-ticket-created': {\n entry: './functions/on-ticket-created.ts',\n trigger: { kind: 'cmsHook', collection: 'tickets', event: 'beforeCreate' },\n scopes: ['cms:read', 'notifications:send'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // SLA automation: hourly sweep of breached tickets (sla_due < now, still\n // open/pending, not yet urgent) \u2192 PATCH priority to 'urgent'. Idempotent\n // by construction \u2014 an escalated ticket falls out of the filter.\n 'sla-sweep': {\n entry: './functions/sla-sweep.ts',\n trigger: { kind: 'cron', schedule: '0 * * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'tickets',\n items: [\n {\n subject: 'Cannot sign in on mobile',\n status: 'open',\n priority: 'normal',\n requester: 'user_demo',\n opened_at: '2026-07-01T09:00:00Z',\n sla_due: '2026-07-01T17:00:00Z',\n body: 'Login on the iOS app spins forever after entering credentials.',\n },\n ],\n },\n ],\n },\n});\n",
19112
- "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\nmkdir my-helpdesk && cd my-helpdesk\nvxil init --template helpdesk\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections + hook + both functions; writes vxil.types.ts\nvxil api POST /v1/users --data '{\"id\":\"user_demo\",\"email\":\"requester@example.com\"}' # the seeded ticket's requester\nvxil seed # one open ticket whose SLA is already past due\n```\n\n**See it work.** The seeded ticket's create fires `on-ticket-created`, which sends the acknowledgement to\nthe ticket's `requester` \u2014 recorded by the `mock` notifications provider (`vxil api GET\n/v1/notifications/deliveries`); `vxil functions logs on-ticket-created` shows the run. The `vxil api POST\n/v1/users` line above matters: the seed's ticket names `user_demo` as its requester but does not create\nthat end-user, and a send to an unknown user answers `404 user_not_found`. The function still finishes, so\nwithout that line the log shows a successful run with `delivery: 404` and no delivery is recorded. Its `sla_due` is\nalready in the past, so run the sweep by hand rather than waiting for the hour:\n\n```bash\nvxil functions invoke sla-sweep\nvxil api GET /v1/cms/items/tickets | jq '.items[0].data.priority' # \u2192 \"urgent\"\n```\n\nRun the sweep again and nothing changes: the `$ne: 'urgent'` term makes it idempotent.\n\n> **Plan note.** The two functions deploy on Free when the project's workload is `staging` or\n> `development` (the `vxil projects workload` step above; create the project with quickstart, not an\n> empty `vxil projects create`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\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 filter DSL (guide ch. 4, querying)\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- **Fail-safe scoping with `strictEndUserScope: true`** \u2014 `ticket_messages` has no owner field, so\n without the flag every signed-in requester could read every thread. With it, an end-user session gets\n `403 server_only` on any collection without an `ownerField`: the thread is written and read by your\n agent backend (server key). Give `ticket_messages` an owner field if requesters should post replies.\n\n```ts\n// `vx` = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! }) \u2014 the key from `vxil env pull`\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 the **storefront** blueprint (a bigger functions saga).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
19726
+ "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// \"Helpdesk\" \u2014 a support-ticketing backend (tickets + threaded messages),\n// declared end-to-end in ONE typed file. A BLUEPRINT composing shipped blocks \u2014\n// \u2022 cms \u2192 tickets \u2192 ticket_messages (resolved by relation), with a\n// Lane-A STATE-MACHINE hook guarding ticket.status\n// \u2022 auth \u2192 requesters sign in as end-users; tickets are owner-scoped\n// \u2022 notifications \u2192 the acknowledgement message on every new ticket\n// \u2022 functions \u2192 the async side: the ack cms-hook + the hourly SLA sweep\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 // Fail-safe owner scoping: a signed-in requester may only touch collections\n // with an `ownerField`. `tickets` has one; `ticket_messages` does not, so\n // without this flag every requester could read every thread. With it, the\n // thread is server-only for end users (your agent backend writes and reads\n // it) \u2014 give it an owner field if requesters should post replies themselves.\n strictEndUserScope: true,\n hooks: {\n // The TICKET STATE MACHINE: reject any illegal status transition,\n // atomically, in the same write (`closed` is terminal). Async\n // side-effects (the ack send, the SLA sweep) live in functions \u2014\n // a Lane-A hook is a pure expression and can't do I/O.\n ticket_transition: {\n collection: 'tickets',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.status == before.status' +\n \" || (before.status == 'open' && (item.status == 'pending' || item.status == 'resolved'))\" +\n \" || (before.status == 'pending' && (item.status == 'open' || item.status == 'resolved'))\" +\n \" || (before.status == 'resolved' && item.status == 'closed')\",\n message: 'illegal ticket status transition',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // requesters are verified end-users\n notifications: { provider: 'mock', fromEmail: 'support@helpdesk.example' },\n functions: { enabled: true },\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 tickets: {\n singular: 'ticket',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a signed-in requester\n // reads/edits ONLY their own tickets \u2014 one declarative flag, a no-op for\n // server callers (your agent backend sees the whole queue).\n ownerField: 'requester',\n fields: {\n subject: { type: 'string', required: true, indexSlot: 's1' },\n status: { type: 'string', indexSlot: 's2' }, // open | pending | resolved | closed (hook-guarded)\n priority: { type: 'string', indexSlot: 's3' }, // low | normal | high | urgent\n requester: { type: 'string', indexSlot: 's4' }, // the owner (end-user) id\n opened_at: { type: 'datetime', indexSlot: 't1' },\n sla_due: { type: 'datetime', indexSlot: 't2' }, // the sla-sweep cron's range filter\n body: { type: 'text' },\n },\n },\n ticket_messages: {\n singular: 'ticket_message',\n fields: {\n ticket: { type: 'relation', relationTo: 'tickets', indexSlot: 's1' },\n author: { type: 'string', indexSlot: 's2' }, // requester or agent id\n body: { type: 'text' },\n sent_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions \u2500\u2500\n functions: {\n // Acknowledgement: on ticket CREATE, RE-FETCH the ticket by id (the cms-hook\n // payload carries only { event, collection, item_id }) and send the requester\n // an acknowledgement through notifications.\n 'on-ticket-created': {\n entry: './functions/on-ticket-created.ts',\n trigger: { kind: 'cmsHook', collection: 'tickets', event: 'beforeCreate' },\n scopes: ['cms:read', 'notifications:send'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // SLA automation: hourly sweep of breached tickets (sla_due < now, still\n // open/pending, not yet urgent) \u2192 PATCH priority to 'urgent'. Idempotent\n // by construction \u2014 an escalated ticket falls out of the filter.\n 'sla-sweep': {\n entry: './functions/sla-sweep.ts',\n trigger: { kind: 'cron', schedule: '0 * * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n },\n\n seed: {\n // The demo requester exists before its ticket does, so on-ticket-created's\n // notification has a real recipient (`vxil seed` writes users first).\n users: [{ id: 'user_demo', email: 'requester@example.com' }],\n cms: [\n {\n collection: 'tickets',\n items: [\n {\n subject: 'Cannot sign in on mobile',\n status: 'open',\n priority: 'normal',\n requester: 'user_demo',\n opened_at: '2026-07-01T09:00:00Z',\n sla_due: '2026-07-01T17:00:00Z',\n body: 'Login on the iOS app spins forever after entering credentials.',\n },\n ],\n },\n ],\n },\n});\n",
19727
+ "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\nmkdir my-helpdesk && cd my-helpdesk\nvxil init --template helpdesk\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections + hook + both functions; writes vxil.types.ts\nvxil seed # the demo requester, then one open ticket whose SLA is already past due\n```\n\n**See it work.** The seeded ticket's create fires `on-ticket-created`, which sends the acknowledgement to\nthe ticket's `requester` \u2014 recorded by the `mock` notifications provider (`vxil api GET\n/v1/notifications/deliveries`); `vxil functions logs on-ticket-created` shows the run. The seed creates\nthe ticket's requester (`user_demo`, in `seed.users`) before the ticket, because a send to an unknown user\nanswers `404 user_not_found`. Its `sla_due` is\nalready in the past, so run the sweep by hand rather than waiting for the hour:\n\n```bash\nvxil functions invoke sla-sweep\nvxil api GET /v1/cms/items/tickets | jq '.items[0].data.priority' # \u2192 \"urgent\"\n```\n\nRun the sweep again and nothing changes: the `$ne: 'urgent'` term makes it idempotent.\n\n> **Plan note.** The two functions deploy on Free when the project's workload is `staging` or\n> `development` (the `vxil projects workload` step above; create the project with quickstart, not an\n> empty `vxil projects create`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\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 filter DSL (guide ch. 4, querying)\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- **Fail-safe scoping with `strictEndUserScope: true`** \u2014 `ticket_messages` has no owner field, so\n without the flag every signed-in requester could read every thread. With it, an end-user session gets\n `403 server_only` on any collection without an `ownerField`: the thread is written and read by your\n agent backend (server key). Give `ticket_messages` an owner field if requesters should post replies.\n\n```ts\n// `vx` = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! }) \u2014 the key from `vxil env pull`\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 the **storefront** blueprint (a bigger functions saga).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
19113
19728
  "functions": {
19114
19729
  "on-ticket-created.ts": "// on-ticket-created.ts \u2014 the ACKNOWLEDGEMENT hook (a vxil function).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `tickets`. On a CREATE, send the\n// requester an acknowledgement through notifications. The cms-hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// ticket by id (through the edge, tenant-scoped) rather than trusting inline fields.\n// Delivery is at-least-once: the envelope idempotency_key rides the send as its\n// Idempotency-Key header, so a redelivered hook never double-sends.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CmsHookFunctionEnvelope;\ninterface TicketData { subject?: string; status?: string; requester?: string; sla_due?: string }\ninterface Item { data?: { data?: TicketData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (guide ch. 8); 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",
19115
19730
  "sla-sweep.ts": "// sla-sweep.ts \u2014 SLA ESCALATION CRON (a vxil function).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep tickets whose sla_due has passed and\n// that are still open/pending \u2014 the cms filter DSL (vxil.com/docs/guide/04-data-with-cms):\n// `status $in` on the s2 slot, `sla_due $lt` on the t2 slot, `priority $ne` on\n// s3 \u2014 all index-served. Each breach is escalated with a PATCH to priority\n// 'urgent'; the $ne term makes re-runs idempotent (an escalated ticket falls out\n// of the filter). The Lane-A state-machine hook still runs on every PATCH; a\n// priority-only write keeps item.status == before.status, so it always passes.\n\n// cron-walk: drains-filter \u2014 an escalated ticket gets priority 'urgent' and leaves the `$ne` filter.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Ticket { item_id: string; data: { subject?: string; priority?: string } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // breached = still open/pending, past its sla_due, not yet urgent\n const filter = enc({\n status: { $in: ['open', 'pending'] },\n sla_due: { $lt: new Date().toISOString() },\n priority: { $ne: 'urgent' },\n });\n const res = await fetch(`${base}/v1/cms/items/tickets?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ error: `query ${res.status}` }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: Ticket[] } };\n const breached = body.data?.items ?? [];\n\n let escalated = 0;\n for (const t of breached) {\n const r = await fetch(`${base}/v1/cms/items/tickets/${t.item_id}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { priority: 'urgent' } }),\n });\n if (r.ok) escalated++;\n }\n return Response.json({ scanned: breached.length, escalated });\n },\n};\n\n// \u2500\u2500 tiny helper (the vxil REST list envelope is { data: { items } }) \u2500\u2500\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n"
@@ -19184,10 +19799,10 @@ export default defineConfig({
19184
19799
  "vxil_read_key",
19185
19800
  "oidc_client_secret"
19186
19801
  ],
19187
- "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// \"Admin console\" \u2014 the back-office every internal-tools team builds first.\n//\n// Staff look up and edit customer accounts and run bulk fixes. Sensitive\n// changes (usage-credit grants, plan overrides, suspensions, data fixes) become\n// REQUESTS that a different person approves. Each account shows its change\n// history and internal notes. Declared end-to-end in ONE typed file:\n//\n// \u2022 auth \u2192 staff only: sign-in through your company identity provider,\n// fenced to your e-mail domain(s); the org role rides the session\n// \u2022 orgs \u2192 ONE staff organization; three custom roles (support, approver,\n// console-admin) are rows you create over the API (README)\n// \u2022 cms \u2192 accounts \xB7 change_requests \xB7 approval_policies, with the\n// approval STATE MACHINE and \"no self-approval\" as write hooks\n// \u2022 comments \u2192 internal notes, one thread per account (`accounts:<id>`),\n// read and written only through the console-api function\n// \u2022 notifications \u2192 \"approval needed\" / \"approval decided\" messages\n// \u2022 functions \u2192 every read AND write a staff member makes goes through one\n// of these, and each one checks the caller's permission first\n// (the browser key holds only functions:invoke)\n//\n// What this is NOT: an approval engine or a visual flow. An approval is a ROW\n// with a state, a hook that guards its transitions, and ONE function that\n// decides it. A second approval step is a second state and a second\n// permission, in data \u2014 never a graph.\n//\n// Credits here are USAGE UNITS your product consumes (API calls, seats-days,\n// exports), never money. Refunds through your own payment provider are out of\n// scope; see the README for the payments-integration functions pattern.\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 auth: {\n // Staff sign in through the company identity provider ONLY: no\n // password, no magic link, so there is no staff password to phish, reset\n // or leak.\n methods: { emailPassword: false, magicLink: false },\n providers: {\n oidc: {\n issuer: 'https://login.example-idp.com', // your IdP's issuer (https, no query/fragment)\n clientId: 'vxil-admin-console', // not a secret \u2014 it rides every authorize URL\n clientSecretRef: 'secret:oidc_client_secret', // the `secrets` block below\n scopes: ['email', 'profile'], // `openid` is always added; add 'groups' if your IdP needs it for the claim\n // `groups` from the IdP rides the session as roles, next to the org role.\n // Keep IdP group names distinct from the console's role names: a group\n // called `console-admin` would pass `readRoles` below.\n claims: { email: 'email', name: 'name', roles: 'groups' },\n // fail-closed to your domain(s), but that is EVERYONE at the company,\n // not only staff. Assign the console's app in your IdP to the staff\n // group too; the functions refuse non-staff either way (403).\n allowedDomains: ['example.com'],\n autoLink: false, // link by the IdP's stable subject only\n },\n },\n // The member's org role (support / approver / console-admin) is embedded\n // in the session at sign-in and refresh, so field-level read gates\n // (`readRoles` below) work with no round-trip. The functions still ask\n // the orgs permission check on every write: that is revocation-grade.\n orgClaims: { enabled: true },\n session: { ttlMinutes: 60, refreshTtlDays: 7 },\n security: {\n // Inert while sign-in is IdP-only (it guards password and one-time-code\n // sign-in); kept so a break-glass password login, if you ever turn one\n // on, is rate-limited from the first attempt.\n lockout: { maxFailures: 5, windowMinutes: 15, lockMinutes: 30 },\n // EVERY return URL must be one of these: the console, and a local dev\n // server (plain http is accepted on localhost / 127.0.0.1 only).\n allowedRedirectOrigins: ['https://console.example.com', 'http://localhost:5173'],\n },\n },\n\n // ONE organization holds your staff. Roles are rows (see the README):\n // support \u2192 accounts.read, changes.request, notes.write\n // approver \u2192 accounts.read, changes.approve\n // console-admin \u2192 all of the above + audit.read, policies.manage\n // The org `owner` holds every permission (break-glass).\n orgs: { enabled: true },\n\n // Records are live data, not editorial content: no draft step.\n cms: {\n draftPublish: false,\n hooks: {\n // \u2500\u2500 The approval state machine \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // pending \u2192 approved | rejected | expired ; approved \u2192 applied | failed.\n // Everything else is terminal. A decision is two writes inside ONE\n // transaction (pending \u2192 approved, then approved \u2192 applied), so the\n // hook sees every step and `approved` is never left behind on success.\n request_stage: {\n collection: 'change_requests',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.state == before.state'\n + \" || (before.state == 'pending' && (item.state == 'approved' || item.state == 'rejected' || item.state == 'expired'))\"\n + \" || (before.state == 'approved' && (item.state == 'applied' || item.state == 'failed'))\",\n message: 'illegal change-request state transition',\n },\n // A request is born pending \u2014 or already applied when a policy\n // auto-approved it (then decided_by is 'policy' and auto is true).\n request_born: {\n collection: 'change_requests',\n event: 'beforeCreate',\n kind: 'validate',\n expr:\n \"item.state == 'pending'\"\n + \" || (item.state == 'applied' && item.auto == true && item.decided_by == 'policy')\",\n message: \"a change request starts 'pending' (or 'applied' by policy)\",\n },\n // \u2500\u2500 Self-approval is impossible BY CONSTRUCTION \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Write hooks see the ROW, not the caller, so the human ids are fields:\n // `requested_by` is set by request-change from the verified session,\n // `decided_by` by decide-change from the verified session. Even a buggy\n // function cannot move a request into approved/applied with the same\n // person on both sides.\n no_self_approval: {\n collection: 'change_requests',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.state == before.state'\n + \" || (item.state != 'approved' && item.state != 'applied')\"\n + ' || (not(isNull(item.decided_by)) && item.decided_by != item.requested_by)',\n message: 'self_approval: the requester cannot approve their own change',\n },\n // Credits are usage units and never go negative. (`$inc` writes skip\n // hooks; the `validation.min` on the field guards those, as a 409.)\n credits_nonneg: {\n collection: 'accounts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'coalesce(item.credits, 0) >= 0',\n message: 'credits cannot go below zero',\n },\n // active \u2194 suspended; anything \u2192 closed; closed is terminal.\n account_status: {\n collection: 'accounts',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.status == before.status'\n + \" || item.status == 'closed'\"\n + \" || (before.status == 'active' && item.status == 'suspended')\"\n + \" || (before.status == 'suspended' && item.status == 'active')\",\n message: 'illegal account status transition',\n },\n },\n },\n\n // Internal notes: one thread per account, topic `accounts:<item_id>`.\n // Comments are author-owned (a signed-in caller can only write as itself).\n comments: {},\n\n // Approval messages go to the inbox and by e-mail. `mock` keeps the\n // walkthrough provider-free; switch to your e-mail provider for real mail.\n notifications: { provider: 'mock', fromEmail: 'console@example.com', inboxEnabled: true },\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 accounts: {\n singular: 'account',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' }, // $startsWith search\n email: { type: 'string', required: true, unique: true, indexSlot: 's2' },\n plan: { type: 'string', indexSlot: 's3' },\n status: { type: 'string', indexSlot: 's4', validation: { enum: ['active', 'suspended', 'closed'] } },\n credits: { type: 'int', indexSlot: 'n1', validation: { min: 0 } }, // usage units, never money\n seats: { type: 'int', indexSlot: 'n2', validation: { min: 0 } },\n created_at: { type: 'datetime', indexSlot: 't1' },\n last_active_at: { type: 'datetime', indexSlot: 't2' },\n // the id of this account in YOUR product database \u2014 equality lookups\n // are index-served without spending a slot\n external_ref: { type: 'string', indexed: true },\n notes_count: { type: 'int', validation: { min: 0 } },\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\n // A signed-in staff member receives these only when the session's\n // role is listed. Everyone else gets the account WITHOUT the field \u2014\n // absent, not null \u2014 and cannot filter or sort on it.\n risk_flags: { type: 'json', readRoles: ['console-admin', 'owner'] },\n billing_contact: { type: 'string', readRoles: ['console-admin', 'approver', 'owner'] },\n },\n // No `actions` buttons: a per-record button writes with the caller's\n // key, and the browser key here holds only functions:invoke by design.\n // Every screen and button in the console calls a function instead.\n },\n\n change_requests: {\n singular: 'change_request',\n fields: {\n kind: { type: 'string', required: true, indexSlot: 's1',\n validation: { enum: ['credit_grant', 'plan_change', 'suspend', 'reactivate', 'data_fix'] } },\n state: { type: 'string', required: true, indexSlot: 's2',\n validation: { enum: ['pending', 'approved', 'rejected', 'expired', 'applied', 'failed'] } },\n requested_by: { type: 'string', required: true, indexSlot: 's3' }, // end-user id, set by the function\n decided_by: { type: 'string', indexSlot: 's4' }, // end-user id, or 'policy'\n amount: { type: 'int', indexSlot: 'n1' }, // credits for credit_grant, else 0\n // `restrict`: an account with change requests cannot be deleted out\n // from under its own history (close it instead).\n account_ref: { type: 'relation', relationTo: 'accounts', onDelete: 'restrict', indexed: true },\n requested_at: { type: 'datetime', indexSlot: 't1' },\n decided_at: { type: 'datetime', indexSlot: 't2' },\n // `<Idempotency-Key>:<account id>` \u2014 unique, so a retried request\n // finds the first row instead of filing (or applying) twice\n request_key: { type: 'string', unique: true },\n payload: { type: 'json' }, // the exact patch to apply (allow-listed per kind)\n reason: { type: 'text', required: true },\n decision_note: { type: 'text' },\n error: { type: 'text' },\n auto: { type: 'bool' },\n },\n },\n\n // Thresholds are ROWS, not code: change \"auto-approve credit grants\n // under 500\" in the dashboard and the next request follows it.\n approval_policies: {\n singular: 'approval_policy',\n fields: {\n kind: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n approver_permission: { type: 'string', indexSlot: 's2' }, // default changes.approve\n auto_approve_below: { type: 'int', indexSlot: 'n1', validation: { min: 0 } }, // absent = never auto\n // the CUMULATIVE caps: a per-grant threshold alone is bypassed by\n // splitting one big grant into many small ones. Rolling 24 h:\n // daily_cap \u2192 SUM of auto-applied amounts, per requester AND per\n // account (absent = the threshold itself);\n // daily_count \u2192 NUMBER of auto-applied grants per requester, held\n // under a lock, so racing calls cannot overshoot the\n // sum cap by more than this many grants (absent = 25).\n // Past either cap, the grant is filed pending for an approver.\n auto_approve_daily_cap: { type: 'int', validation: { min: 0 } },\n auto_approve_daily_count: { type: 'int', validation: { min: 1 } },\n expires_after_hours: { type: 'int', indexSlot: 'n2', validation: { min: 0 } }, // 0 = never expires\n enabled: { type: 'bool' },\n },\n },\n },\n },\n\n functions: {\n // File one change request per target account (1\u201325 = a bulk fix). Small\n // credit grants under the policy threshold apply at once, atomically with\n // their request row; everything else waits for an approver.\n 'request-change': {\n entry: './functions/request-change.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'orgs:read', 'notifications:send'],\n egressAllow: [],\n signature: {\n input: {\n kind: \"'credit_grant' | 'plan_change' | 'suspend' | 'reactivate' | 'data_fix'\",\n account_ids: 'string[]',\n amount: 'number | undefined',\n payload: 'Record<string, unknown> | undefined',\n reason: 'string',\n },\n output: '{ results: Array<{ account_id: string; request_id?: string; state: string; error?: string }> }',\n },\n },\n\n // Approve or reject ONE pending request. Refuses the requester (403\n // self_approval; the hook is the backstop). An approval applies the change\n // in the same transaction, guarded on `state == pending`.\n 'decide-change': {\n entry: './functions/decide-change.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'orgs:read', 'notifications:send'],\n egressAllow: [],\n signature: {\n input: { request_id: 'string', decision: \"'approve' | 'reject'\", note: 'string | undefined' },\n output: '{ request_id: string; state: string; error?: string }',\n },\n },\n\n // EVERY staff read (account search, one account, the request queue, the\n // policies, an account's notes) plus writing a note, each op behind the\n // orgs permission check. The browser key holds only functions:invoke, so\n // a signed-in person outside the staff org reads nothing (see the README).\n 'console-api': {\n entry: './functions/console-api.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'orgs:read', 'comments:read', 'comments:write'],\n egressAllow: [],\n signature: {\n input: {\n op: \"'search' | 'account' | 'requests' | 'policies' | 'notes' | 'add_note' | undefined\",\n q: 'string | undefined',\n by: \"'email' | 'external_ref' | 'name' | undefined\",\n status: 'string | undefined',\n plan: 'string | undefined',\n sort: 'string | undefined',\n id: 'string | undefined',\n state: 'string | undefined',\n account_id: 'string | undefined',\n mine: 'boolean | undefined',\n body: 'string | undefined',\n cursor: 'string | undefined',\n limit: 'number | undefined',\n },\n output: 'unknown',\n },\n },\n\n // The change history of one account (or one request): the human-attributed\n // change requests, plus the platform audit rows that name the record.\n 'audit-trail': {\n entry: './functions/audit-trail.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'orgs:read'],\n // An API key of THIS backend holding `features:read`, because the audit\n // stream is not on the function's scoped callback. Be clear about what\n // that key is: `features:read` is NOT audit-only. It also opens every\n // feature's data export (all tenant rows, end users, transcripts,\n // function source), so it is a FULL-PROJECT EXPORT CREDENTIAL. It lives\n // only in this function's secret: never in a browser bundle, a client\n // app, a log line or a second function.\n secrets: ['secret:vxil_read_key'],\n egressAllow: [],\n signature: {\n input: { subject: 'string', cursor: 'string | undefined' },\n output: '{ requests: unknown[]; events: unknown[]; next_cursor: string | null; scanned: number; truncated: boolean }',\n },\n },\n\n // Hourly: pending requests older than their policy's expires_after_hours\n // become `expired`, and the requester is told.\n 'expire-pending': {\n entry: './functions/expire-pending.ts',\n trigger: { kind: 'cron', schedule: '0 * * * *' },\n scopes: ['cms:read', 'cms:write', 'notifications:send'],\n egressAllow: [],\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 your staff identity provider' },\n vxil_read_key: { feature: 'functions', description: 'a vxil API key holding features:read (a full-project export credential, not audit-only) \u2014 the audit-trail function reads the audit stream with it; never put it in a browser' },\n },\n\n seed: {\n cms: [\n {\n collection: 'approval_policies',\n items: [\n // credit grants under 500 units apply at once, up to 5,000 units (and 25\n // grants) a day per requester and 5,000 a day per account; past that, or\n // at 500 and above, they wait 72 h for an approver\n { kind: 'credit_grant', approver_permission: 'changes.approve', auto_approve_below: 500, auto_approve_daily_cap: 5000, auto_approve_daily_count: 25, expires_after_hours: 72, enabled: true },\n // a plan override always needs an approver (no auto_approve_below)\n { kind: 'plan_change', approver_permission: 'changes.approve', expires_after_hours: 48, enabled: true },\n ],\n },\n {\n collection: 'accounts',\n items: [\n { name: 'Acme Analytics', email: 'ops@acme-analytics.example', plan: 'growth', status: 'active', credits: 12000, seats: 25, created_at: '2025-11-03T09:00:00Z', last_active_at: '2026-09-30T16:12:00Z', external_ref: 'cus_1001', notes_count: 0, billing_contact: 'finance@acme-analytics.example', risk_flags: [] },\n { name: 'Bluebird Labs', email: 'admin@bluebird.example', plan: 'starter', status: 'active', credits: 800, seats: 5, created_at: '2026-02-14T10:30:00Z', last_active_at: '2026-10-01T08:45:00Z', external_ref: 'cus_1002', notes_count: 0, billing_contact: 'ap@bluebird.example', risk_flags: [] },\n { name: 'Cobalt Freight', email: 'it@cobalt-freight.example', plan: 'enterprise', status: 'active', credits: 250000, seats: 140, created_at: '2024-06-20T12:00:00Z', last_active_at: '2026-10-02T11:20:00Z', external_ref: 'cus_1003', notes_count: 0, billing_contact: 'procurement@cobalt-freight.example', risk_flags: ['high_usage'] },\n { name: 'Driftwood Studio', email: 'hello@driftwood.example', plan: 'starter', status: 'suspended', credits: 0, seats: 2, created_at: '2026-05-01T15:00:00Z', last_active_at: '2026-08-11T19:05:00Z', external_ref: 'cus_1004', notes_count: 0, billing_contact: 'hello@driftwood.example', risk_flags: ['chargeback_history'] },\n { name: 'Evergreen Health', email: 'platform@evergreen.example', plan: 'growth', status: 'active', credits: 4300, seats: 32, created_at: '2025-08-09T07:30:00Z', last_active_at: '2026-09-29T13:40:00Z', external_ref: 'cus_1005', notes_count: 0, billing_contact: 'billing@evergreen.example', risk_flags: [] },\n ],\n },\n ],\n },\n});\n",
19188
- "readme": "# Admin console (saas)\n\nThe back-office every internal-tools team builds first. Staff sign in through **your company identity\nprovider**, look up and edit customer accounts, and fix up to 25 accounts at once. Sensitive changes\n(usage-credit grants, plan overrides, suspensions, data fixes) become **requests that someone else\napproves**. Each account shows its change history and internal notes.\n\nApprovals here are **rows plus one deciding function**: a state on a record, a hook that guards its\ntransitions, and one function that decides. There is no approval engine and no visual flow.\n\n```bash\nmkdir my-console && cd my-console\nvxil init --template admin-console # init scaffolds into the CURRENT directory\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nprintf '%s' \"$OIDC_SECRET\" | vxil secrets set auth/oidc_client_secret # or run it bare for a hidden prompt\n# the audit-trail function's key: audit:read reads the audit stream and nothing else (see \"Audit trail\")\nvxil login # keys mint needs a dashboard session, not a project key\nvxil keys mint --name console-audit-read --scopes audit:read --json | jq -r .api_key | vxil secrets set functions/vxil_read_key\nvxil push # AFTER the three OIDC edits below; collections + hooks + five functions\nvxil seed # demo accounts + approval policies\n```\n\nThe mint line needs `jq`: it pipes the new key straight into the secret, so it never shows in your\nterminal. (Without `jq`, run the mint with `--json`, copy `api_key`, and paste it at the hidden prompt of\n`vxil secrets set functions/vxil_read_key`.)\n\n`vxil link --key` stores the key you give it in `~/.vxil/credentials.json` (so does `vxil login` with its session): treat that file as a secret \u2014 never commit it, copy it into an image or share it.\n\nBefore you push, edit three values in `vxil.config.ts`: `issuer`, `clientId` and `allowedDomains` in\n`features.auth.providers.oidc`. Also set the console URL in `allowedRedirectOrigins` and in\n`CONSOLE_URL` at the top of `functions/request-change.ts`, `decide-change.ts` and\n`expire-pending.ts`, which is the link in every approval message.\n\n> **Plan note.** Five functions and one hourly cron fit the Free plan's counts (5 functions, 3 cron\n> triggers). The functions deploy on Free when the project's workload is `staging` or `development`\n> (the `vxil projects workload` step above). On a Free `production` project, `vxil push` stops\n> before it writes anything and names the ways out: change the workload, upgrade to Developer, or run\n> `vxil push --skip-functions`.\n\n## What it provisions\n\n| Collection | What a row is | Teaches |\n|---|---|---|\n| `accounts` | one customer account: name, e-mail (unique), plan, status, **credits** (usage units, never money), seats, your product's own id (`external_ref`) | list, filter and sort on index slots; a status state machine; two **role-gated fields** |\n| `change_requests` | one requested change to one account: kind, state, who asked, who decided, the exact patch, the reason | approvals as data; self-approval impossible by construction; compare-and-set decisions |\n| `approval_policies` | one row per kind: who may approve it, the auto-approve threshold, when a request expires | thresholds as rows, not code |\n\n| Function | Trigger | What it does |\n|---|---|---|\n| `request-change` | http | files one request per target account (1\u201325), or applies it at once under the policy threshold |\n| `decide-change` | http | approves or rejects one pending request; an approval applies it in the same transaction |\n| `console-api` | http | every staff read (account search and lookup, the request queue, the policies, an account's notes) and writing a note, each behind the permission check |\n| `audit-trail` | http | one account's (or one request's) history: the requests and the platform audit rows |\n| `expire-pending` | cron, hourly | pending requests past their policy's deadline become `expired` |\n\nPlus internal notes (comments, through `console-api`) and approval messages (inbox and e-mail).\n\n## Set up the staff organization (once, over the API)\n\nRoles are rows, so this needs no deploy. Use a server key (`vxil api` uses the CLI's key):\n\n```bash\nvxil api POST /v1/orgs/roles --data '{\"role_key\":\"support\",\"name\":\"Support\",\"permissions\":[\"accounts.read\",\"changes.request\",\"notes.write\"]}'\nvxil api POST /v1/orgs/roles --data '{\"role_key\":\"approver\",\"name\":\"Approver\",\"permissions\":[\"accounts.read\",\"changes.approve\"]}'\nvxil api POST /v1/orgs/roles --data '{\"role_key\":\"console-admin\",\"name\":\"Console admin\",\"permissions\":[\"accounts.read\",\"changes.request\",\"changes.approve\",\"notes.write\",\"audit.read\",\"policies.manage\"]}'\n```\n\nEach person signs in once through the identity provider, which creates their user. Then create the\nstaff organization with its first owner and seat everyone else on a role:\n\n```bash\nvxil api GET \"/v1/users?email=lead@example.com\" # \u2192 the user id\nvxil api POST /v1/orgs --data '{\"slug\":\"staff\",\"name\":\"Staff\",\"owner_user_id\":\"<lead>\"}'\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<sam>\",\"role\":\"support\"}'\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<ana>\",\"role\":\"approver\"}'\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<cat>\",\"role\":\"console-admin\"}'\n```\n\nThe org `owner` holds every permission (`*`). Keep that role for one or two break-glass people.\n\nRun the console in **its own project**, holding only staff. Every function looks up the caller's\nactive organization and asks the orgs permission check inside it, so a project that also holds\nyour customers' organizations would let a customer org's owner pass those checks in their own org.\n\n## Who can sign in, and why the browser reads nothing directly\n\n**`allowedDomains` lets in everyone at your company domain, not only staff.** Any colleague\nwith an `@example.com` identity can complete sign-in and get a session. Two things keep that\nfrom becoming access to customer data:\n\n1. **Assign the console's app in your identity provider to the staff group only** (in most\n providers: the app's assignment or access policy). Then a colleague outside the group is\n stopped at the provider and never gets a session. Do this first.\n2. **The browser key reads nothing on its own.** Even with a session, every read and write goes\n through a function that asks the orgs permission check first, so a signed-in person who is not\n in the staff org gets `403` from every function and sees no account, request or note.\n\nThe console's browser bundle holds two keys:\n\n| Key | Class | Scopes |\n|---|---|---|\n| `console-auth` | server | `auth:signin` (starting the identity-provider sign-in, refreshing the session) |\n| `console-data` | **public / thin-client** (dashboard \u2192 API keys) | `functions:invoke` **only** |\n\n`console-data` is minted as the thin-client class, so the edge refuses any request on it that does\nnot carry a signed-in session. A copy lifted out of the bundle calls nothing.\n\n**Why the browser key holds no `cms:read` or `comments:*`.** None of these collections has an\nowner field: every staff member works on every account. A collection read is checked against the\nkey's scopes, not against the staff org, so a browser key holding `cms:read` would let *anyone who\ncan sign in* (the whole domain, if step 1 is skipped) list every customer account with its e-mail.\nA key holding `comments:read` / `comments:write` would let them read and write internal notes. A key\nholding `cms:write` would let any staff member write any row with no permission check, no approval\nand no request row. So every screen calls `console-api` (reads and notes) or one of the change\nfunctions, and each one asks the orgs permission check (revocation-grade, so a removed role takes\neffect on the next call, not at the next sign-in). For the same reason the collections declare no\nper-record action buttons: a button writes with the caller's key.\n\n**Keep IdP group names distinct from the console's role names.** The `groups` claim rides the\nsession as roles next to the org role. A group literally named `console-admin` or `approver` would\npass the `readRoles` field gates below. The permission checks in the functions read the org\nmembership, not the group, so they are not affected.\n\n## Back-office over your own data\n\n- **List, filter, sort.** Every column a staff screen filters or sorts on is in an index slot:\n `status`, `plan`, `credits`, `seats`, `created_at`, `last_active_at`. For example, \"suspended\n accounts on growth, most credits first\" is\n `console-api` `{ op: 'search', status: 'suspended', plan: 'growth', sort: '-credits' }`, which the\n function runs as one indexed `GET /v1/cms/items/accounts?filter=\u2026&sort=-credits`. Sorted lists page\n with `cursor`, the same as the default order.\n- **Lookup.** `console-api` `{ op: 'search', q, by }`: `email` is an exact match on the unique slot,\n `external_ref` an exact match on an indexed field (your product's own id, without spending a\n slot), and `name` a case-sensitive `$startsWith` prefix. `{ op: 'account', id }` reads one account;\n `{ op: 'requests', state: 'pending' }` is the approver's queue (`account_id` narrows it to one\n account, `mine: true` to your own requests).\n- **Bulk fixes.** `request-change` takes up to 25 `account_ids`, which is one batch call of up to 25\n operations. Each account gets its own request row and its own result: `pending`, `applied`,\n `duplicate`, or `rejected` with `account_not_found`.\n- **Atomic multi-record changes.** An approval writes the request and the account in one atomic\n batch (`POST /v1/cms/batch` with `atomic: true`). Either both change or neither does.\n\n## Approvals as data, plus one function\n\n**The state machine is a hook.** `request_stage` allows only `pending \u2192 approved | rejected |\nexpired` and `approved \u2192 applied | failed`. Every other state is terminal. `request_born` allows a\nrequest to start only as `pending`, or as `applied` when a policy auto-approved it.\n\n**Self-approval is impossible by construction.** Write hooks see the row, not the caller, so the\nhuman ids are fields. `request-change` sets `requested_by` from the verified session, and\n`decide-change` sets `decided_by` the same way. The `no_self_approval` hook refuses any move into\n`approved` or `applied` where the two are equal. `decide-change` refuses the requester first with\n`403 self_approval`. The hook is the backstop, so a bug in the function cannot open the hole.\n\n**Auto-approve thresholds are rows, not code.** The seed has two policies:\n\n| kind | approver_permission | auto_approve_below | expires_after_hours |\n|---|---|---|---|\n| `credit_grant` | `changes.approve` | 500 | 72 |\n| `plan_change` | `changes.approve` | (none: never auto) | 48 |\n\nA credit grant of 499 or fewer units applies at once, as an `applied` request with `auto: true` and\n`decided_by: 'policy'`, written in the same atomic batch as the account change. A grant of 500 or\nmore waits for an approver. Edit the row and the next request follows it. A kind with no row never\nauto-applies and expires after 72 hours. Only `credit_grant` can auto-apply, because only it\ncarries an amount.\n\n**The threshold is also a daily budget, so splitting a grant does not skip the approver.** A\nper-grant threshold on its own is easy to get around: twenty grants of 499 to the same account are\n9,980 credits that nobody approved. So the `credit_grant` row also carries two rolling 24-hour caps:\n\n| field | seed | what it caps |\n|---|---|---|\n| `auto_approve_daily_cap` | 5,000 | the **sum** of auto-applied grants per requester, and separately per account |\n| `auto_approve_daily_count` | 25 | the **number** of auto-applied grants per requester |\n\nBefore it auto-applies anything, `request-change` adds up what was auto-applied in the last 24 hours\n(one aggregate over the requester's `decided_by: 'policy'` rows, one bounded read for the target\naccounts) and keeps a running total across a bulk call. A grant that would cross either sum cap is\nfiled `pending` with `reason: 'daily_auto_cap'` in its result, and the approvers are told as usual.\nIf a total cannot be read, the grant is filed `pending` too (fail closed). Leave\n`auto_approve_daily_cap` out and it defaults to the threshold itself (one threshold's worth a day).\n\nThe totals are a read, so two calls sent at the same instant could both pass them. The count cap is\nthe backstop for that: each auto-applied row is written under one lock per requester with a guard\nthat allows at most `auto_approve_daily_count` such rows in 24 hours. Racing calls can overshoot the\nsum cap by at most that many sub-threshold grants (25 \xD7 499 with the seed), never without bound.\nLower the count if that worst case is too high for you.\n\n**Two approvers can never both apply.** The decision's first write is conditional\n(`if: { state: 'pending' }`). Two approvers pressing at once both read `pending`. The second one's\nbatch finds the state already `approved` and gets a clean `409 already_decided`. The account\nchanges once.\n\n**A refused change is recorded.** When the account itself refuses the write (a closed account, a\ngrant that would take credits below zero, an e-mail another account already uses), the batch rolls\nback. A second batch then records the decision and the refusal: `pending \u2192 approved \u2192 failed`, with\nthe error on the row.\n\n**Expiry.** `expire-pending` runs hourly. Pending requests older than their kind's\n`expires_after_hours` become `expired` (0 means never), and the requester is told. Each write is\nconditional on `pending`, so a request decided in the same instant keeps its decision.\n\n**The payload is allow-listed per kind.** `request-change` refuses (`422 invalid_payload`) any\n`payload` key outside this list, before anything is written. `decide-change` applies the stored\npayload through the same list again.\n\n| kind | payload | applied as |\n|---|---|---|\n| `credit_grant` | none (`amount` 1\u20131,000,000) | `$inc: { credits: amount }` |\n| `plan_change` | `{ plan }` | `{ plan }` |\n| `suspend` / `reactivate` | none | `{ status: 'suspended' }` / `{ status: 'active' }` |\n| `data_fix` | any of `{ name, email, seats }` | those fields |\n\n**Retries.** Send an `Idempotency-Key` header with each button press. The function writes\n`<Idempotency-Key>:<account id>` into the request's unique `request_key` field, so a retried press\nfinds the first row (`state: 'duplicate'`) and never files or applies twice. The cms routes do\n**not** honor the `Idempotency-Key` header themselves. That unique field is the dedupe. The\nnotification sends **do** honor it, so each approver, and each requester per decision, is messaged\nonce. Without the header, every call counts as a new request.\n\n**Multi-step approvals are more data, not a graph.** For \"two approvals above 10,000 credits\", add a\nsecond state (`approved_1`) and a second permission (`changes.approve_large`) to the hook and the\npolicy row. `decide-change` stays one function that moves one state.\n\n## Who sees which field\n\n`risk_flags` (json) declares `readRoles: ['console-admin', 'owner']`, and `billing_contact` declares\n`['console-admin', 'approver', 'owner']`. `console-api` reads with the caller's session, so a support\nmember receives accounts **without** those fields: they are absent, not null, and cannot be used in a\nfilter or a sort. The role comes from the session (`orgClaims`, plus the IdP `groups`; see the\ngroup-name note above), so a role change applies at the member's next sign-in or refresh. Your own\nserver key still sees every field.\n\n## Internal notes\n\nNotes are comments on the topic `accounts:<item_id>`, read and written through `console-api`:\n\n```ts\nconst fn = vx.asEndUser(sessionToken).fn['console-api'];\nawait fn({ op: 'add_note', account_id: id, body: 'Customer asked to pause billing until May.' }); // needs notes.write\nconst { notes } = await fn({ op: 'notes', account_id: id }); // needs accounts.read\n```\n\nThe function writes the note with the caller's session, so the author is always the signed-in staff\nmember (a different `author_id` is refused). `notes_count` is a plain field; bump it from a function\nholding `cms:write` if a screen needs the count without loading the thread.\n\n## Audit trail per record\n\n`audit-trail` takes `{ subject: 'accounts:<id>' }` (or `'change_requests:<id>'`) and needs\n`audit.read`. It returns two halves:\n\n- **requests** are the change requests for the account: who asked, who decided, why, the exact\n patch, and the outcome. This half names the people.\n- **events** are the platform's own audit rows for the record: every create, update (with the\n incremented fields of a credit grant) and delete. They are kept 90 days on Free and 365 days on\n paid plans. Every one of them is recorded as **the project** (actor `tenant`), not as the staff\n member who pressed the button: the audit row names the record, never the person. The person is\n recorded in the request row (`requested_by`, `decided_by`), which is why the request rows exist.\n A note is audited the same way (`comment.created`, as the project); its author is on the note.\n\nThe shipped function reads the audit stream (`GET /v1/audit`) with `vxil_read_key`, an API key holding\n`audit:read`. That key is a holdover, not a platform limit: a function that declares the `audit:read`\nscope reaches `/v1/audit` through its own scoped callback, with no stored key, and you can switch\n`functions/audit-trail.ts` over to that. It scans the 1,000 most recent tenant\nevents per call. `next_cursor` continues the scan, and `truncated: true` says there is more.\nReads are not audited.\n\n> **Mint `vxil_read_key` with `audit:read`, not `features:read`.** Both open `/v1/audit`, but\n> `features:read` also opens every feature's data export: all tenant rows, the end-user records,\n> assistant transcripts and function source. (The comments in `vxil.config.ts` and the function still\n> name `features:read`; `audit:read` is the narrower scope that does the same job here.) Whichever you\n> use, it is stored only as this one function's secret and is never sent to the browser, so a staff\n> session cannot see it. Never put it in a client bundle, a log line, or a second function, and rotate\n> it if it ever leaves the secret store. If you do not need the platform half of the trail, delete the\n> `events` half and the secret, and keep the request rows.\n\n## If your customer records live in your own database\n\nKeep the requests, approvals, policies, notes and messages here, and move two things:\n\n1. **`console-api`'s `search` and `account` ops**: keep their request and response shape, and replace the bodies with one HTTPS\n call to your database's query endpoint. The pattern is a function, a secret and one egress host\n (vxil.com/docs/guide/08-running-your-code-functions, \"Your own database from a function\").\n2. **The apply step in `decide-change`**: replace the batch's account write with a call to your own\n API. Then `approved` becomes a resting state (the decision is recorded), and your API's answer\n moves the request to `applied` or `failed`.\n\n## What this is not\n\n- **Not a generic approval engine.** There is no rule language and no designer. The rules are one\n hook, one policy row per kind, and a function you read top to bottom.\n- **Not a visual flow.** A second step is a second state and a second permission, in data.\n- **Not a payments surface.** Credits are usage units your product consumes. To refund money\n through your own payment provider, add a function with `payments:write` that calls\n `POST /v1/payments/refunds` (the payments integration: your provider account, never in the flow of\n funds), and file it as one more request kind.\n\n## Pairs with\n\n- the **team-workspace** blueprint: the same orgs, roles and `readRoles` machinery for your customers'\n teams.\n- the **helpdesk** blueprint: the customer-facing side that these staff screens answer.\n- the `helpdesk` and `microblog` showcase apps (https://vxil.com/showcase, downloadable as zips): a browser app with the same two-key split\n and sign-in shell to build the console UI on.\n",
19802
+ "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// \"Admin console\" \u2014 the back-office every internal-tools team builds first.\n//\n// Staff look up and edit customer accounts and run bulk fixes. Sensitive\n// changes (usage-credit grants, plan overrides, suspensions, data fixes) become\n// REQUESTS that a different person approves. Each account shows its change\n// history and internal notes. Declared end-to-end in ONE typed file:\n//\n// \u2022 auth \u2192 staff only: sign-in through your company identity provider,\n// fenced to your e-mail domain(s); the org role rides the session\n// \u2022 orgs \u2192 ONE staff organization; three custom roles (support, approver,\n// console-admin) are rows you create over the API (README)\n// \u2022 cms \u2192 accounts \xB7 change_requests \xB7 approval_policies, with the\n// approval STATE MACHINE and \"no self-approval\" as write hooks\n// \u2022 comments \u2192 internal notes, one thread per account (`accounts:<id>`),\n// read and written only through the console-api function\n// \u2022 notifications \u2192 \"approval needed\" / \"approval decided\" messages\n// \u2022 functions \u2192 every read AND write a staff member makes goes through one\n// of these, and each one checks the caller's permission first\n// (the browser key holds only functions:invoke)\n//\n// What this is NOT: an approval engine or a visual flow. An approval is a ROW\n// with a state, a hook that guards its transitions, and ONE function that\n// decides it. A second approval step is a second state and a second\n// permission, in data \u2014 never a graph.\n//\n// Credits here are USAGE UNITS your product consumes (API calls, seats-days,\n// exports), never money. Refunds through your own payment provider are out of\n// scope; see the README for the payments-integration functions pattern.\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 auth: {\n // Staff sign in through the company identity provider ONLY: no\n // password, no magic link, so there is no staff password to phish, reset\n // or leak.\n methods: { emailPassword: false, magicLink: false },\n providers: {\n oidc: {\n issuer: 'https://login.example-idp.com', // your IdP's issuer (https, no query/fragment)\n clientId: 'vxil-admin-console', // not a secret \u2014 it rides every authorize URL\n clientSecretRef: 'secret:oidc_client_secret', // the `secrets` block below\n scopes: ['email', 'profile'], // `openid` is always added; add 'groups' if your IdP needs it for the claim\n // `groups` from the IdP rides the session as roles, next to the org role.\n // Keep IdP group names distinct from the console's role names: a group\n // called `console-admin` would pass `readRoles` below.\n claims: { email: 'email', name: 'name', roles: 'groups' },\n // fail-closed to your domain(s), but that is EVERYONE at the company,\n // not only staff. Assign the console's app in your IdP to the staff\n // group too; the functions refuse non-staff either way (403).\n allowedDomains: ['example.com'],\n autoLink: false, // link by the IdP's stable subject only\n },\n },\n // The member's org role (support / approver / console-admin) is embedded\n // in the session at sign-in and refresh, so field-level read gates\n // (`readRoles` below) work with no round-trip. The functions still ask\n // the orgs permission check on every write: that is revocation-grade.\n orgClaims: { enabled: true },\n session: { ttlMinutes: 60, refreshTtlDays: 7 },\n security: {\n // Inert while sign-in is IdP-only (it guards password and one-time-code\n // sign-in); kept so a break-glass password login, if you ever turn one\n // on, is rate-limited from the first attempt.\n lockout: { maxFailures: 5, windowMinutes: 15, lockMinutes: 30 },\n // EVERY return URL must be one of these: the console, and a local dev\n // server (plain http is accepted on localhost / 127.0.0.1 only).\n allowedRedirectOrigins: ['https://console.example.com', 'http://localhost:5173'],\n },\n },\n\n // ONE organization holds your staff. Roles are rows (see the README):\n // support \u2192 accounts.read, changes.request, notes.write\n // approver \u2192 accounts.read, changes.approve\n // console-admin \u2192 all of the above + audit.read, policies.manage\n // The org `owner` holds every permission (break-glass).\n orgs: { enabled: true },\n\n // Records are live data, not editorial content: no draft step.\n cms: {\n draftPublish: false,\n hooks: {\n // \u2500\u2500 The approval state machine \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // pending \u2192 approved | rejected | expired ; approved \u2192 applied | failed.\n // Everything else is terminal. A decision is two writes inside ONE\n // transaction (pending \u2192 approved, then approved \u2192 applied), so the\n // hook sees every step and `approved` is never left behind on success.\n request_stage: {\n collection: 'change_requests',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.state == before.state'\n + \" || (before.state == 'pending' && (item.state == 'approved' || item.state == 'rejected' || item.state == 'expired'))\"\n + \" || (before.state == 'approved' && (item.state == 'applied' || item.state == 'failed'))\",\n message: 'illegal change-request state transition',\n },\n // A request is born pending \u2014 or already applied when a policy\n // auto-approved it (then decided_by is 'policy' and auto is true).\n request_born: {\n collection: 'change_requests',\n event: 'beforeCreate',\n kind: 'validate',\n expr:\n \"item.state == 'pending'\"\n + \" || (item.state == 'applied' && item.auto == true && item.decided_by == 'policy')\",\n message: \"a change request starts 'pending' (or 'applied' by policy)\",\n },\n // \u2500\u2500 Self-approval is impossible BY CONSTRUCTION \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Write hooks see the ROW, not the caller, so the human ids are fields:\n // `requested_by` is set by request-change from the verified session,\n // `decided_by` by decide-change from the verified session. Even a buggy\n // function cannot move a request into approved/applied with the same\n // person on both sides.\n no_self_approval: {\n collection: 'change_requests',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.state == before.state'\n + \" || (item.state != 'approved' && item.state != 'applied')\"\n + ' || (not(isNull(item.decided_by)) && item.decided_by != item.requested_by)',\n message: 'self_approval: the requester cannot approve their own change',\n },\n // Credits are usage units and never go negative. (`$inc` writes skip\n // hooks; the `validation.min` on the field guards those, as a 409.)\n credits_nonneg: {\n collection: 'accounts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'coalesce(item.credits, 0) >= 0',\n message: 'credits cannot go below zero',\n },\n // active \u2194 suspended; anything \u2192 closed; closed is terminal.\n account_status: {\n collection: 'accounts',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.status == before.status'\n + \" || item.status == 'closed'\"\n + \" || (before.status == 'active' && item.status == 'suspended')\"\n + \" || (before.status == 'suspended' && item.status == 'active')\",\n message: 'illegal account status transition',\n },\n },\n },\n\n // Internal notes: one thread per account, topic `accounts:<item_id>`.\n // Comments are author-owned (a signed-in caller can only write as itself).\n comments: {},\n\n // Approval messages go to the inbox and by e-mail. `mock` keeps the\n // walkthrough provider-free; switch to your e-mail provider for real mail.\n notifications: { provider: 'mock', fromEmail: 'console@example.com', inboxEnabled: true },\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 accounts: {\n singular: 'account',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' }, // $startsWith search\n email: { type: 'string', required: true, unique: true, indexSlot: 's2' },\n plan: { type: 'string', indexSlot: 's3' },\n status: { type: 'string', indexSlot: 's4', validation: { enum: ['active', 'suspended', 'closed'] } },\n credits: { type: 'int', indexSlot: 'n1', validation: { min: 0 } }, // usage units, never money\n seats: { type: 'int', indexSlot: 'n2', validation: { min: 0 } },\n created_at: { type: 'datetime', indexSlot: 't1' },\n last_active_at: { type: 'datetime', indexSlot: 't2' },\n // the id of this account in YOUR product database \u2014 equality lookups\n // are index-served without spending a slot\n external_ref: { type: 'string', indexed: true },\n notes_count: { type: 'int', validation: { min: 0 } },\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\n // A signed-in staff member receives these only when the session's\n // role is listed. Everyone else gets the account WITHOUT the field \u2014\n // absent, not null \u2014 and cannot filter or sort on it.\n risk_flags: { type: 'json', readRoles: ['console-admin', 'owner'] },\n billing_contact: { type: 'string', readRoles: ['console-admin', 'approver', 'owner'] },\n },\n // No `actions` buttons: a per-record button writes with the caller's\n // key, and the browser key here holds only functions:invoke by design.\n // Every screen and button in the console calls a function instead.\n },\n\n change_requests: {\n singular: 'change_request',\n fields: {\n kind: { type: 'string', required: true, indexSlot: 's1',\n validation: { enum: ['credit_grant', 'plan_change', 'suspend', 'reactivate', 'data_fix'] } },\n state: { type: 'string', required: true, indexSlot: 's2',\n validation: { enum: ['pending', 'approved', 'rejected', 'expired', 'applied', 'failed'] } },\n requested_by: { type: 'string', required: true, indexSlot: 's3' }, // end-user id, set by the function\n decided_by: { type: 'string', indexSlot: 's4' }, // end-user id, or 'policy'\n amount: { type: 'int', indexSlot: 'n1' }, // credits for credit_grant, else 0\n // `restrict`: an account with change requests cannot be deleted out\n // from under its own history (close it instead).\n account_ref: { type: 'relation', relationTo: 'accounts', onDelete: 'restrict', indexed: true },\n requested_at: { type: 'datetime', indexSlot: 't1' },\n decided_at: { type: 'datetime', indexSlot: 't2' },\n // `<Idempotency-Key>:<account id>` \u2014 unique, so a retried request\n // finds the first row instead of filing (or applying) twice\n request_key: { type: 'string', unique: true },\n payload: { type: 'json' }, // the exact patch to apply (allow-listed per kind)\n reason: { type: 'text', required: true },\n decision_note: { type: 'text' },\n error: { type: 'text' },\n auto: { type: 'bool' },\n },\n },\n\n // Thresholds are ROWS, not code: change \"auto-approve credit grants\n // under 500\" in the dashboard and the next request follows it.\n approval_policies: {\n singular: 'approval_policy',\n fields: {\n kind: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n approver_permission: { type: 'string', indexSlot: 's2' }, // default changes.approve\n auto_approve_below: { type: 'int', indexSlot: 'n1', validation: { min: 0 } }, // absent = never auto\n // the CUMULATIVE caps: a per-grant threshold alone is bypassed by\n // splitting one big grant into many small ones. Rolling 24 h:\n // daily_cap \u2192 SUM of auto-applied amounts, per requester AND per\n // account (absent = the threshold itself);\n // daily_count \u2192 NUMBER of auto-applied grants per requester, held\n // under a lock, so racing calls cannot overshoot the\n // sum cap by more than this many grants (absent = 25).\n // Past either cap, the grant is filed pending for an approver.\n auto_approve_daily_cap: { type: 'int', validation: { min: 0 } },\n auto_approve_daily_count: { type: 'int', validation: { min: 1 } },\n expires_after_hours: { type: 'int', indexSlot: 'n2', validation: { min: 0 } }, // 0 = never expires\n enabled: { type: 'bool' },\n },\n },\n },\n },\n\n functions: {\n // File one change request per target account (1\u201325 = a bulk fix). Small\n // credit grants under the policy threshold apply at once, atomically with\n // their request row; everything else waits for an approver.\n 'request-change': {\n entry: './functions/request-change.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'orgs:read', 'notifications:send'],\n egressAllow: [],\n signature: {\n input: {\n kind: \"'credit_grant' | 'plan_change' | 'suspend' | 'reactivate' | 'data_fix'\",\n account_ids: 'string[]',\n amount: 'number | undefined',\n payload: 'Record<string, unknown> | undefined',\n reason: 'string',\n },\n output: '{ results: Array<{ account_id: string; request_id?: string; state: string; error?: string }> }',\n },\n },\n\n // Approve or reject ONE pending request. Refuses the requester (403\n // self_approval; the hook is the backstop). An approval applies the change\n // in the same transaction, guarded on `state == pending`.\n 'decide-change': {\n entry: './functions/decide-change.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'orgs:read', 'notifications:send'],\n egressAllow: [],\n signature: {\n input: { request_id: 'string', decision: \"'approve' | 'reject'\", note: 'string | undefined' },\n output: '{ request_id: string; state: string; error?: string }',\n },\n },\n\n // EVERY staff read (account search, one account, the request queue, the\n // policies, an account's notes) plus writing a note, each op behind the\n // orgs permission check. The browser key holds only functions:invoke, so\n // a signed-in person outside the staff org reads nothing (see the README).\n 'console-api': {\n entry: './functions/console-api.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'orgs:read', 'comments:read', 'comments:write'],\n egressAllow: [],\n signature: {\n input: {\n op: \"'search' | 'account' | 'requests' | 'policies' | 'notes' | 'add_note' | undefined\",\n q: 'string | undefined',\n by: \"'email' | 'external_ref' | 'name' | undefined\",\n status: 'string | undefined',\n plan: 'string | undefined',\n sort: 'string | undefined',\n id: 'string | undefined',\n state: 'string | undefined',\n account_id: 'string | undefined',\n mine: 'boolean | undefined',\n body: 'string | undefined',\n cursor: 'string | undefined',\n limit: 'number | undefined',\n },\n output: 'unknown',\n },\n },\n\n // The change history of one account (or one request): the human-attributed\n // change requests, plus the platform audit rows that name the record.\n 'audit-trail': {\n entry: './functions/audit-trail.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'orgs:read'],\n // An API key of THIS backend holding ONLY `audit:read`. This function\n // runs in end-user mode (a staff session), and the audit stream is a\n // project-wide read that answers an end-user principal 403 server_only \u2014\n // so its own scoped callback cannot read it; the key reads it as the\n // project. It lives only in this function's secret: never in a browser\n // bundle, a client app, a log line or a second function.\n secrets: ['secret:vxil_read_key'],\n egressAllow: [],\n signature: {\n input: { subject: 'string', cursor: 'string | undefined' },\n output: '{ requests: unknown[]; events: unknown[]; next_cursor: string | null; scanned: number; truncated: boolean }',\n },\n },\n\n // Hourly: pending requests older than their policy's expires_after_hours\n // become `expired`, and the requester is told.\n 'expire-pending': {\n entry: './functions/expire-pending.ts',\n trigger: { kind: 'cron', schedule: '0 * * * *' },\n scopes: ['cms:read', 'cms:write', 'notifications:send'],\n egressAllow: [],\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 your staff identity provider' },\n vxil_read_key: { feature: 'functions', description: 'a vxil API key holding ONLY audit:read \u2014 the audit-trail function reads the audit stream with it; never put it in a browser' },\n },\n\n seed: {\n cms: [\n {\n collection: 'approval_policies',\n items: [\n // credit grants under 500 units apply at once, up to 5,000 units (and 25\n // grants) a day per requester and 5,000 a day per account; past that, or\n // at 500 and above, they wait 72 h for an approver\n { kind: 'credit_grant', approver_permission: 'changes.approve', auto_approve_below: 500, auto_approve_daily_cap: 5000, auto_approve_daily_count: 25, expires_after_hours: 72, enabled: true },\n // a plan override always needs an approver (no auto_approve_below)\n { kind: 'plan_change', approver_permission: 'changes.approve', expires_after_hours: 48, enabled: true },\n ],\n },\n {\n collection: 'accounts',\n items: [\n { name: 'Acme Analytics', email: 'ops@acme-analytics.example', plan: 'growth', status: 'active', credits: 12000, seats: 25, created_at: '2025-11-03T09:00:00Z', last_active_at: '2026-09-30T16:12:00Z', external_ref: 'cus_1001', notes_count: 0, billing_contact: 'finance@acme-analytics.example', risk_flags: [] },\n { name: 'Bluebird Labs', email: 'admin@bluebird.example', plan: 'starter', status: 'active', credits: 800, seats: 5, created_at: '2026-02-14T10:30:00Z', last_active_at: '2026-10-01T08:45:00Z', external_ref: 'cus_1002', notes_count: 0, billing_contact: 'ap@bluebird.example', risk_flags: [] },\n { name: 'Cobalt Freight', email: 'it@cobalt-freight.example', plan: 'enterprise', status: 'active', credits: 250000, seats: 140, created_at: '2024-06-20T12:00:00Z', last_active_at: '2026-10-02T11:20:00Z', external_ref: 'cus_1003', notes_count: 0, billing_contact: 'procurement@cobalt-freight.example', risk_flags: ['high_usage'] },\n { name: 'Driftwood Studio', email: 'hello@driftwood.example', plan: 'starter', status: 'suspended', credits: 0, seats: 2, created_at: '2026-05-01T15:00:00Z', last_active_at: '2026-08-11T19:05:00Z', external_ref: 'cus_1004', notes_count: 0, billing_contact: 'hello@driftwood.example', risk_flags: ['chargeback_history'] },\n { name: 'Evergreen Health', email: 'platform@evergreen.example', plan: 'growth', status: 'active', credits: 4300, seats: 32, created_at: '2025-08-09T07:30:00Z', last_active_at: '2026-09-29T13:40:00Z', external_ref: 'cus_1005', notes_count: 0, billing_contact: 'billing@evergreen.example', risk_flags: [] },\n ],\n },\n ],\n },\n});\n",
19803
+ "readme": "# Admin console (saas)\n\nThe back-office every internal-tools team builds first. Staff sign in through **your company identity\nprovider**, look up and edit customer accounts, and fix up to 25 accounts at once. Sensitive changes\n(usage-credit grants, plan overrides, suspensions, data fixes) become **requests that someone else\napproves**. Each account shows its change history and internal notes.\n\nApprovals here are **rows plus one deciding function**: a state on a record, a hook that guards its\ntransitions, and one function that decides. There is no approval engine and no visual flow.\n\n```bash\nmkdir my-console && cd my-console\nvxil init --template admin-console # init scaffolds into the CURRENT directory\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nprintf '%s' \"$OIDC_SECRET\" | vxil secrets set auth/oidc_client_secret # or run it bare for a hidden prompt\n# the audit-trail function's key: audit:read reads the audit stream and nothing else (see \"Audit trail\")\nvxil login # keys mint needs a dashboard session, not a project key\nvxil keys mint --name console-audit-read --scopes audit:read --json | jq -r .api_key | vxil secrets set functions/vxil_read_key\nvxil push # AFTER the three OIDC edits below; collections + hooks + five functions\nvxil seed # demo accounts + approval policies\n```\n\nThe mint line needs `jq`: it pipes the new key straight into the secret, so it never shows in your\nterminal. (Without `jq`, run the mint with `--json`, copy `api_key`, and paste it at the hidden prompt of\n`vxil secrets set functions/vxil_read_key`.)\n\n`vxil link --key` stores the key you give it in `~/.vxil/credentials.json` (so does `vxil login` with its session): treat that file as a secret \u2014 never commit it, copy it into an image or share it.\n\nBefore you push, edit three values in `vxil.config.ts`: `issuer`, `clientId` and `allowedDomains` in\n`features.auth.providers.oidc`. Also set the console URL in `allowedRedirectOrigins` and in\n`CONSOLE_URL` at the top of `functions/request-change.ts`, `decide-change.ts` and\n`expire-pending.ts`, which is the link in every approval message.\n\n> **Plan note.** Five functions and one hourly cron fit the Free plan's counts (5 functions, 3 cron\n> triggers). The functions deploy on Free when the project's workload is `staging` or `development`\n> (the `vxil projects workload` step above). On a Free `production` project, `vxil push` stops\n> before it writes anything and names the ways out: change the workload, upgrade to Developer, or run\n> `vxil push --skip-functions`.\n\n## What it provisions\n\n| Collection | What a row is | Teaches |\n|---|---|---|\n| `accounts` | one customer account: name, e-mail (unique), plan, status, **credits** (usage units, never money), seats, your product's own id (`external_ref`) | list, filter and sort on index slots; a status state machine; two **role-gated fields** |\n| `change_requests` | one requested change to one account: kind, state, who asked, who decided, the exact patch, the reason | approvals as data; self-approval impossible by construction; compare-and-set decisions |\n| `approval_policies` | one row per kind: who may approve it, the auto-approve threshold, when a request expires | thresholds as rows, not code |\n\n| Function | Trigger | What it does |\n|---|---|---|\n| `request-change` | http | files one request per target account (1\u201325), or applies it at once under the policy threshold |\n| `decide-change` | http | approves or rejects one pending request; an approval applies it in the same transaction |\n| `console-api` | http | every staff read (account search and lookup, the request queue, the policies, an account's notes) and writing a note, each behind the permission check |\n| `audit-trail` | http | one account's (or one request's) history: the requests and the platform audit rows |\n| `expire-pending` | cron, hourly | pending requests past their policy's deadline become `expired` |\n\nPlus internal notes (comments, through `console-api`) and approval messages (inbox and e-mail).\n\n## Set up the staff organization (once, over the API)\n\nRoles are rows, so this needs no deploy. Use a server key (`vxil api` uses the CLI's key):\n\n```bash\nvxil api POST /v1/orgs/roles --data '{\"role_key\":\"support\",\"name\":\"Support\",\"permissions\":[\"accounts.read\",\"changes.request\",\"notes.write\"]}'\nvxil api POST /v1/orgs/roles --data '{\"role_key\":\"approver\",\"name\":\"Approver\",\"permissions\":[\"accounts.read\",\"changes.approve\"]}'\nvxil api POST /v1/orgs/roles --data '{\"role_key\":\"console-admin\",\"name\":\"Console admin\",\"permissions\":[\"accounts.read\",\"changes.request\",\"changes.approve\",\"notes.write\",\"audit.read\",\"policies.manage\"]}'\n```\n\nEach person signs in once through the identity provider, which creates their user. Then create the\nstaff organization with its first owner and seat everyone else on a role:\n\n```bash\nvxil api GET \"/v1/users?email=lead@example.com\" # \u2192 the user id\nvxil api POST /v1/orgs --data '{\"slug\":\"staff\",\"name\":\"Staff\",\"owner_user_id\":\"<lead>\"}'\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<sam>\",\"role\":\"support\"}'\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<ana>\",\"role\":\"approver\"}'\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<cat>\",\"role\":\"console-admin\"}'\n```\n\nThe org `owner` holds every permission (`*`). Keep that role for one or two break-glass people.\n\nRun the console in **its own project**, holding only staff. Every function looks up the caller's\nactive organization and asks the orgs permission check inside it, so a project that also holds\nyour customers' organizations would let a customer org's owner pass those checks in their own org.\n\n## Who can sign in, and why the browser reads nothing directly\n\n**`allowedDomains` lets in everyone at your company domain, not only staff.** Any colleague\nwith an `@example.com` identity can complete sign-in and get a session. Two things keep that\nfrom becoming access to customer data:\n\n1. **Assign the console's app in your identity provider to the staff group only** (in most\n providers: the app's assignment or access policy). Then a colleague outside the group is\n stopped at the provider and never gets a session. Do this first.\n2. **The browser key reads nothing on its own.** Even with a session, every read and write goes\n through a function that asks the orgs permission check first, so a signed-in person who is not\n in the staff org gets `403` from every function and sees no account, request or note.\n\nThe console's browser bundle holds two keys:\n\n| Key | Class | Scopes |\n|---|---|---|\n| `console-auth` | server | `auth:signin` (starting the identity-provider sign-in, refreshing the session) |\n| `console-data` | **public / thin-client** (dashboard \u2192 API keys) | `functions:invoke` **only** |\n\n`console-data` is minted as the thin-client class, so the edge refuses any request on it that does\nnot carry a signed-in session. A copy lifted out of the bundle calls nothing.\n\n**Why the browser key holds no `cms:read` or `comments:*`.** None of these collections has an\nowner field: every staff member works on every account. A collection read is checked against the\nkey's scopes, not against the staff org, so a browser key holding `cms:read` would let *anyone who\ncan sign in* (the whole domain, if step 1 is skipped) list every customer account with its e-mail.\nA key holding `comments:read` / `comments:write` would let them read and write internal notes. A key\nholding `cms:write` would let any staff member write any row with no permission check, no approval\nand no request row. So every screen calls `console-api` (reads and notes) or one of the change\nfunctions, and each one asks the orgs permission check (revocation-grade, so a removed role takes\neffect on the next call, not at the next sign-in). For the same reason the collections declare no\nper-record action buttons: a button writes with the caller's key.\n\n**Keep IdP group names distinct from the console's role names.** The `groups` claim rides the\nsession as roles next to the org role. A group literally named `console-admin` or `approver` would\npass the `readRoles` field gates below. The permission checks in the functions read the org\nmembership, not the group, so they are not affected.\n\n## Back-office over your own data\n\n- **List, filter, sort.** Every column a staff screen filters or sorts on is in an index slot:\n `status`, `plan`, `credits`, `seats`, `created_at`, `last_active_at`. For example, \"suspended\n accounts on growth, most credits first\" is\n `console-api` `{ op: 'search', status: 'suspended', plan: 'growth', sort: '-credits' }`, which the\n function runs as one indexed `GET /v1/cms/items/accounts?filter=\u2026&sort=-credits`. Sorted lists page\n with `cursor`, the same as the default order.\n- **Lookup.** `console-api` `{ op: 'search', q, by }`: `email` is an exact match on the unique slot,\n `external_ref` an exact match on an indexed field (your product's own id, without spending a\n slot), and `name` a case-sensitive `$startsWith` prefix. `{ op: 'account', id }` reads one account;\n `{ op: 'requests', state: 'pending' }` is the approver's queue (`account_id` narrows it to one\n account, `mine: true` to your own requests).\n- **Bulk fixes.** `request-change` takes up to 25 `account_ids`, which is one batch call of up to 25\n operations. Each account gets its own request row and its own result: `pending`, `applied`,\n `duplicate`, or `rejected` with `account_not_found`.\n- **Atomic multi-record changes.** An approval writes the request and the account in one atomic\n batch (`POST /v1/cms/batch` with `atomic: true`). Either both change or neither does.\n\n## Approvals as data, plus one function\n\n**The state machine is a hook.** `request_stage` allows only `pending \u2192 approved | rejected |\nexpired` and `approved \u2192 applied | failed`. Every other state is terminal. `request_born` allows a\nrequest to start only as `pending`, or as `applied` when a policy auto-approved it.\n\n**Self-approval is impossible by construction.** Write hooks see the row, not the caller, so the\nhuman ids are fields. `request-change` sets `requested_by` from the verified session, and\n`decide-change` sets `decided_by` the same way. The `no_self_approval` hook refuses any move into\n`approved` or `applied` where the two are equal. `decide-change` refuses the requester first with\n`403 self_approval`. The hook is the backstop, so a bug in the function cannot open the hole.\n\n**Auto-approve thresholds are rows, not code.** The seed has two policies:\n\n| kind | approver_permission | auto_approve_below | expires_after_hours |\n|---|---|---|---|\n| `credit_grant` | `changes.approve` | 500 | 72 |\n| `plan_change` | `changes.approve` | (none: never auto) | 48 |\n\nA credit grant of 499 or fewer units applies at once, as an `applied` request with `auto: true` and\n`decided_by: 'policy'`, written in the same atomic batch as the account change. A grant of 500 or\nmore waits for an approver. Edit the row and the next request follows it. A kind with no row never\nauto-applies and expires after 72 hours. Only `credit_grant` can auto-apply, because only it\ncarries an amount.\n\n**The threshold is also a daily budget, so splitting a grant does not skip the approver.** A\nper-grant threshold on its own is easy to get around: twenty grants of 499 to the same account are\n9,980 credits that nobody approved. So the `credit_grant` row also carries two rolling 24-hour caps:\n\n| field | seed | what it caps |\n|---|---|---|\n| `auto_approve_daily_cap` | 5,000 | the **sum** of auto-applied grants per requester, and separately per account |\n| `auto_approve_daily_count` | 25 | the **number** of auto-applied grants per requester |\n\nBefore it auto-applies anything, `request-change` adds up what was auto-applied in the last 24 hours\n(one aggregate over the requester's `decided_by: 'policy'` rows, one bounded read for the target\naccounts) and keeps a running total across a bulk call. A grant that would cross either sum cap is\nfiled `pending` with `reason: 'daily_auto_cap'` in its result, and the approvers are told as usual.\nIf a total cannot be read, the grant is filed `pending` too (fail closed). Leave\n`auto_approve_daily_cap` out and it defaults to the threshold itself (one threshold's worth a day).\n\nThe totals are a read, so two calls sent at the same instant could both pass them. The count cap is\nthe backstop for that: each auto-applied row is written under one lock per requester with a guard\nthat allows at most `auto_approve_daily_count` such rows in 24 hours. Racing calls can overshoot the\nsum cap by at most that many sub-threshold grants (25 \xD7 499 with the seed), never without bound.\nLower the count if that worst case is too high for you.\n\n**Two approvers can never both apply.** The decision's first write is conditional\n(`if: { state: 'pending' }`). Two approvers pressing at once both read `pending`. The second one's\nbatch finds the state already `approved` and gets a clean `409 already_decided`. The account\nchanges once.\n\n**A refused change is recorded.** When the account itself refuses the write (a closed account, a\ngrant that would take credits below zero, an e-mail another account already uses), the batch rolls\nback. A second batch then records the decision and the refusal: `pending \u2192 approved \u2192 failed`, with\nthe error on the row.\n\n**Expiry.** `expire-pending` runs hourly. Pending requests older than their kind's\n`expires_after_hours` become `expired` (0 means never), and the requester is told. Each write is\nconditional on `pending`, so a request decided in the same instant keeps its decision.\n\n**The payload is allow-listed per kind.** `request-change` refuses (`422 invalid_payload`) any\n`payload` key outside this list, before anything is written. `decide-change` applies the stored\npayload through the same list again.\n\n| kind | payload | applied as |\n|---|---|---|\n| `credit_grant` | none (`amount` 1\u20131,000,000) | `$inc: { credits: amount }` |\n| `plan_change` | `{ plan }` | `{ plan }` |\n| `suspend` / `reactivate` | none | `{ status: 'suspended' }` / `{ status: 'active' }` |\n| `data_fix` | any of `{ name, email, seats }` | those fields |\n\n**Retries.** Send an `Idempotency-Key` header with each button press. The function writes\n`<Idempotency-Key>:<account id>` into the request's unique `request_key` field, so a retried press\nfinds the first row (`state: 'duplicate'`) and never files or applies twice. The cms routes do\n**not** honor the `Idempotency-Key` header themselves. That unique field is the dedupe. The\nnotification sends **do** honor it, so each approver, and each requester per decision, is messaged\nonce. Without the header, every call counts as a new request.\n\n**Multi-step approvals are more data, not a graph.** For \"two approvals above 10,000 credits\", add a\nsecond state (`approved_1`) and a second permission (`changes.approve_large`) to the hook and the\npolicy row. `decide-change` stays one function that moves one state.\n\n## Who sees which field\n\n`risk_flags` (json) declares `readRoles: ['console-admin', 'owner']`, and `billing_contact` declares\n`['console-admin', 'approver', 'owner']`. `console-api` reads with the caller's session, so a support\nmember receives accounts **without** those fields: they are absent, not null, and cannot be used in a\nfilter or a sort. The role comes from the session (`orgClaims`, plus the IdP `groups`; see the\ngroup-name note above), so a role change applies at the member's next sign-in or refresh. Your own\nserver key still sees every field.\n\n## Internal notes\n\nNotes are comments on the topic `accounts:<item_id>`, read and written through `console-api`:\n\n```ts\nconst fn = vx.asEndUser(sessionToken).fn['console-api'];\nawait fn({ op: 'add_note', account_id: id, body: 'Customer asked to pause billing until May.' }); // needs notes.write\nconst { notes } = await fn({ op: 'notes', account_id: id }); // needs accounts.read\n```\n\nThe function writes the note with the caller's session, so the author is always the signed-in staff\nmember (a different `author_id` is refused). `notes_count` is a plain field; bump it from a function\nholding `cms:write` if a screen needs the count without loading the thread.\n\n## Audit trail per record\n\n`audit-trail` takes `{ subject: 'accounts:<id>' }` (or `'change_requests:<id>'`) and needs\n`audit.read`. It returns two halves:\n\n- **requests** are the change requests for the account: who asked, who decided, why, the exact\n patch, and the outcome. This half names the people.\n- **events** are the platform's own audit rows for the record: every create, update (with the\n incremented fields of a credit grant) and delete. They are kept 90 days on Free and 365 days on\n paid plans. Every one of them is recorded as **the project** (actor `tenant`), not as the staff\n member who pressed the button: the audit row names the record, never the person. The person is\n recorded in the request row (`requested_by`, `decided_by`), which is why the request rows exist.\n A note is audited the same way (`comment.created`, as the project); its author is on the note.\n\nThe shipped function reads the audit stream (`GET /v1/audit`) with `vxil_read_key`, an API key holding\n`audit:read`. The key is needed here because `audit-trail` runs in end-user mode (a staff session): the\naudit stream is a project-wide read that answers an end-user principal `403 server_only`, so a declared\n`audit:read` scope on the function would not reach it. (A server-mode function, such as a cron, reads it\nwith the declared scope and no stored key.) It scans the 1,000 most recent tenant\nevents per call. `next_cursor` continues the scan, and `truncated: true` says there is more.\nReads are not audited.\n\n> **Mint `vxil_read_key` with `audit:read`, not `features:read`.** Both open `/v1/audit`, but\n> `features:read` also opens every feature's data export: all tenant rows, the end-user records,\n> assistant transcripts and function source. It is stored only as this one function's secret and is never sent to the browser, so a staff\n> session cannot see it. Never put it in a client bundle, a log line, or a second function, and rotate\n> it if it ever leaves the secret store. If you do not need the platform half of the trail, delete the\n> `events` half and the secret, and keep the request rows.\n\n## If your customer records live in your own database\n\nKeep the requests, approvals, policies, notes and messages here, and move two things:\n\n1. **`console-api`'s `search` and `account` ops**: keep their request and response shape, and replace the bodies with one HTTPS\n call to your database's query endpoint. The pattern is a function, a secret and one egress host\n (vxil.com/docs/guide/08-running-your-code-functions, \"Your own database from a function\").\n2. **The apply step in `decide-change`**: replace the batch's account write with a call to your own\n API. Then `approved` becomes a resting state (the decision is recorded), and your API's answer\n moves the request to `applied` or `failed`.\n\n## What this is not\n\n- **Not a generic approval engine.** There is no rule language and no designer. The rules are one\n hook, one policy row per kind, and a function you read top to bottom.\n- **Not a visual flow.** A second step is a second state and a second permission, in data.\n- **Not a payments surface.** Credits are usage units your product consumes. To refund money\n through your own payment provider, add a function with `payments:write` that calls\n `POST /v1/payments/refunds` (the payments integration: your provider account, never in the flow of\n funds), and file it as one more request kind.\n\n## Pairs with\n\n- the **team-workspace** blueprint: the same orgs, roles and `readRoles` machinery for your customers'\n teams.\n- the **helpdesk** blueprint: the customer-facing side that these staff screens answer.\n- the `helpdesk` and `microblog` showcase apps (https://vxil.com/showcase, downloadable as zips): a browser app with the same two-key split\n and sign-in shell to build the console UI on.\n",
19189
19804
  "functions": {
19190
- "audit-trail.ts": "// audit-trail.ts \u2014 THE HISTORY OF ONE RECORD (a vxil function, http, end-user mode).\n//\n// POST /v1/fn/audit-trail { subject: 'accounts:<id>' | 'change_requests:<id>', cursor? }\n//\n// Two halves, because they answer different questions:\n//\n// requests \u2014 WHO asked for what, WHO decided, WHY. The change_requests rows\n// for the account (or the one request): requester, approver,\n// reason, decision note, the exact patch, the outcome. Every\n// change made through this console has one, so this is the\n// human-attributed trail.\n// events \u2014 WHAT the platform recorded on the record itself: every create,\n// update (with the incremented fields for a credit grant) and\n// delete, from the tenant audit stream, kept 90 days on Free and\n// 365 days on paid plans. These rows name the record but not the\n// human: every write is audited as the project (actor `tenant`),\n// not as the staff member, which is exactly why the request rows\n// above (requested_by / decided_by) exist.\n//\n// The audit stream is not reachable through the function's scoped callback, so\n// it is read with `vxil_read_key`: an API key of THIS backend holding\n// `features:read`. That scope is NOT audit-only: it also opens every feature's\n// data export, so the key is a FULL-PROJECT EXPORT CREDENTIAL. It lives only in\n// this function's secret; it is never returned, logged, or sent to a browser.\n// The stream is filtered here (newest first, a bounded number\n// of pages per call; `next_cursor` continues the scan, `truncated` says there\n// is more). Reads are not audited.\n//\n// Requires `audit.read` (console-admin).\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst AUDIT_PAGE = 200; // the audit list's own maximum page\nconst MAX_AUDIT_PAGES = 5; // \u22641,000 most recent tenant events scanned per call\nconst MAX_REQUESTS = 50;\nconst COLLECTIONS = new Set(['accounts', 'change_requests']);\n\ninterface Payload { subject?: unknown; cursor?: unknown }\ntype Env = HttpFunctionEnvelope<Payload>;\ninterface AuditRow { id: number | string; event: string; actor: string; created_at: string; payload?: Record<string, unknown> | null }\n\nconst hdrs = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\nconst fail = (status: number, error: string, extra: Record<string, unknown> = {}) =>\n Response.json({ error, ...extra }, { status });\n\nasync function requirePermission(base: string, orgs: string, userId: string, permission: string): Promise<string | Response> {\n const c = await fetch(`${base}/v1/orgs/session-claims?user_id=${encodeURIComponent(userId)}`, { headers: hdrs(orgs) });\n if (!c.ok) return fail(502, 'orgs_unavailable', { status: c.status });\n const orgId = ((await c.json()) as { data?: { org_id?: string | null } }).data?.org_id;\n if (!orgId) return fail(403, 'forbidden', { permission, message: 'you are not a member of the staff organization' });\n const r = await fetch(\n `${base}/v1/orgs/${encodeURIComponent(orgId)}/check?user_id=${encodeURIComponent(userId)}&permission=${encodeURIComponent(permission)}`,\n { headers: hdrs(orgs) },\n );\n if (!r.ok) return fail(502, 'orgs_unavailable', { status: r.status });\n const allowed = ((await r.json()) as { data?: { allowed?: boolean } }).data?.allowed === true;\n return allowed ? orgId : fail(403, 'forbidden', { permission, message: `you need the '${permission}' permission` });\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, orgs } = env.scoped_jwts ?? {};\n const readKey = env.secrets?.vxil_read_key;\n const me = env.end_user?.id;\n if (!me) return fail(401, 'sign_in_required', { message: 'call this with a signed-in staff session' });\n if (!cms || !orgs) return fail(500, 'missing_scopes');\n if (!readKey) return fail(409, 'missing_secret', { message: 'store an API key holding features:read as functions/vxil_read_key (a full-project export credential: keep it only in this secret)' });\n\n const m = typeof env.payload?.subject === 'string' ? /^([a-z_]+):([A-Za-z0-9_-]{1,64})$/.exec(env.payload.subject) : null;\n if (!m || !COLLECTIONS.has(m[1]!)) {\n return fail(422, 'invalid_payload', { message: \"subject must be 'accounts:<id>' or 'change_requests:<id>'\" });\n }\n const [, collection, itemId] = m as unknown as [string, string, string];\n\n const orgId = await requirePermission(base, orgs, me, 'audit.read');\n if (orgId instanceof Response) return orgId;\n\n // \u2500\u2500 half 1: the human-attributed change requests \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 let requests: unknown[] = [];\n if (collection === 'accounts') {\n const r = await fetch(\n `${base}/v1/cms/items/change_requests?filter=${enc({ account_ref: itemId })}&limit=${MAX_REQUESTS}`,\n { headers: hdrs(cms) },\n );\n if (r.ok) {\n const items = ((await r.json()) as { data?: { items?: Array<{ item_id: string; data: Record<string, unknown> }> } }).data?.items ?? [];\n requests = items.map((i) => ({ request_id: i.item_id, ...i.data }));\n }\n } else {\n const r = await fetch(`${base}/v1/cms/items/change_requests/${itemId}`, { headers: hdrs(cms) });\n if (r.ok) {\n const i = ((await r.json()) as { data?: { item_id: string; data: Record<string, unknown> } }).data;\n if (i) requests = [{ request_id: i.item_id, ...i.data }];\n }\n }\n\n // \u2500\u2500 half 2: the platform audit rows that name this record \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const events: Array<{ id: string; event: string; actor: string; at: string; fields: string[] | null }> = [];\n let cursor = typeof env.payload?.cursor === 'string' && /^\\d+$/.test(env.payload.cursor) ? env.payload.cursor : null;\n let scanned = 0;\n for (let page = 0; page < MAX_AUDIT_PAGES; page++) {\n const r = await fetch(`${base}/v1/audit?limit=${AUDIT_PAGE}${cursor ? `&cursor=${cursor}` : ''}`, {\n headers: { authorization: `Bearer ${readKey}` },\n });\n if (!r.ok) return fail(502, 'audit_unavailable', { status: r.status });\n const body = ((await r.json()) as { data?: { events?: AuditRow[]; next_cursor?: string | null } }).data;\n const rows = body?.events ?? [];\n scanned += rows.length;\n for (const row of rows) {\n const pl = row.payload ?? {};\n if (pl.collection !== collection || pl.item_id !== itemId) continue;\n const inc = pl.inc && typeof pl.inc === 'object' ? Object.keys(pl.inc as object) : null;\n events.push({ id: String(row.id), event: row.event, actor: row.actor, at: row.created_at, fields: inc });\n }\n cursor = body?.next_cursor ?? null;\n if (!cursor) break;\n }\n\n return Response.json({ subject: `${collection}:${itemId}`, requests, events, next_cursor: cursor, scanned, truncated: cursor !== null });\n },\n};\n",
19805
+ "audit-trail.ts": "// audit-trail.ts \u2014 THE HISTORY OF ONE RECORD (a vxil function, http, end-user mode).\n//\n// POST /v1/fn/audit-trail { subject: 'accounts:<id>' | 'change_requests:<id>', cursor? }\n//\n// Two halves, because they answer different questions:\n//\n// requests \u2014 WHO asked for what, WHO decided, WHY. The change_requests rows\n// for the account (or the one request): requester, approver,\n// reason, decision note, the exact patch, the outcome. Every\n// change made through this console has one, so this is the\n// human-attributed trail.\n// events \u2014 WHAT the platform recorded on the record itself: every create,\n// update (with the incremented fields for a credit grant) and\n// delete, from the tenant audit stream, kept 90 days on Free and\n// 365 days on paid plans. These rows name the record but not the\n// human: every write is audited as the project (actor `tenant`),\n// not as the staff member, which is exactly why the request rows\n// above (requested_by / decided_by) exist.\n//\n// This function runs in END-USER mode, and the audit stream answers an end-user\n// principal 403 server_only \u2014 so its own scoped callback cannot read it. It is\n// read with `vxil_read_key`: an API key of THIS backend holding ONLY\n// `audit:read`. It lives only in this function's secret; it is never returned,\n// logged, or sent to a browser.\n// The stream is filtered here (newest first, a bounded number\n// of pages per call; `next_cursor` continues the scan, `truncated` says there\n// is more). Reads are not audited.\n//\n// Requires `audit.read` (console-admin).\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst AUDIT_PAGE = 200; // the audit list's own maximum page\nconst MAX_AUDIT_PAGES = 5; // \u22641,000 most recent tenant events scanned per call\nconst MAX_REQUESTS = 50;\nconst COLLECTIONS = new Set(['accounts', 'change_requests']);\n\ninterface Payload { subject?: unknown; cursor?: unknown }\ntype Env = HttpFunctionEnvelope<Payload>;\ninterface AuditRow { id: number | string; event: string; actor: string; created_at: string; payload?: Record<string, unknown> | null }\n\nconst hdrs = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\nconst fail = (status: number, error: string, extra: Record<string, unknown> = {}) =>\n Response.json({ error, ...extra }, { status });\n\nasync function requirePermission(base: string, orgs: string, userId: string, permission: string): Promise<string | Response> {\n const c = await fetch(`${base}/v1/orgs/session-claims?user_id=${encodeURIComponent(userId)}`, { headers: hdrs(orgs) });\n if (!c.ok) return fail(502, 'orgs_unavailable', { status: c.status });\n const orgId = ((await c.json()) as { data?: { org_id?: string | null } }).data?.org_id;\n if (!orgId) return fail(403, 'forbidden', { permission, message: 'you are not a member of the staff organization' });\n const r = await fetch(\n `${base}/v1/orgs/${encodeURIComponent(orgId)}/check?user_id=${encodeURIComponent(userId)}&permission=${encodeURIComponent(permission)}`,\n { headers: hdrs(orgs) },\n );\n if (!r.ok) return fail(502, 'orgs_unavailable', { status: r.status });\n const allowed = ((await r.json()) as { data?: { allowed?: boolean } }).data?.allowed === true;\n return allowed ? orgId : fail(403, 'forbidden', { permission, message: `you need the '${permission}' permission` });\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, orgs } = env.scoped_jwts ?? {};\n const readKey = env.secrets?.vxil_read_key;\n const me = env.end_user?.id;\n if (!me) return fail(401, 'sign_in_required', { message: 'call this with a signed-in staff session' });\n if (!cms || !orgs) return fail(500, 'missing_scopes');\n if (!readKey) return fail(409, 'missing_secret', { message: 'store an API key holding only audit:read as functions/vxil_read_key (keep it only in this secret)' });\n\n const m = typeof env.payload?.subject === 'string' ? /^([a-z_]+):([A-Za-z0-9_-]{1,64})$/.exec(env.payload.subject) : null;\n if (!m || !COLLECTIONS.has(m[1]!)) {\n return fail(422, 'invalid_payload', { message: \"subject must be 'accounts:<id>' or 'change_requests:<id>'\" });\n }\n const [, collection, itemId] = m as unknown as [string, string, string];\n\n const orgId = await requirePermission(base, orgs, me, 'audit.read');\n if (orgId instanceof Response) return orgId;\n\n // \u2500\u2500 half 1: the human-attributed change requests \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 let requests: unknown[] = [];\n if (collection === 'accounts') {\n const r = await fetch(\n `${base}/v1/cms/items/change_requests?filter=${enc({ account_ref: itemId })}&limit=${MAX_REQUESTS}`,\n { headers: hdrs(cms) },\n );\n if (r.ok) {\n const items = ((await r.json()) as { data?: { items?: Array<{ item_id: string; data: Record<string, unknown> }> } }).data?.items ?? [];\n requests = items.map((i) => ({ request_id: i.item_id, ...i.data }));\n }\n } else {\n const r = await fetch(`${base}/v1/cms/items/change_requests/${itemId}`, { headers: hdrs(cms) });\n if (r.ok) {\n const i = ((await r.json()) as { data?: { item_id: string; data: Record<string, unknown> } }).data;\n if (i) requests = [{ request_id: i.item_id, ...i.data }];\n }\n }\n\n // \u2500\u2500 half 2: the platform audit rows that name this record \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const events: Array<{ id: string; event: string; actor: string; at: string; fields: string[] | null }> = [];\n let cursor = typeof env.payload?.cursor === 'string' && /^\\d+$/.test(env.payload.cursor) ? env.payload.cursor : null;\n let scanned = 0;\n for (let page = 0; page < MAX_AUDIT_PAGES; page++) {\n const r = await fetch(`${base}/v1/audit?limit=${AUDIT_PAGE}${cursor ? `&cursor=${cursor}` : ''}`, {\n headers: { authorization: `Bearer ${readKey}` },\n });\n if (!r.ok) return fail(502, 'audit_unavailable', { status: r.status });\n const body = ((await r.json()) as { data?: { events?: AuditRow[]; next_cursor?: string | null } }).data;\n const rows = body?.events ?? [];\n scanned += rows.length;\n for (const row of rows) {\n const pl = row.payload ?? {};\n if (pl.collection !== collection || pl.item_id !== itemId) continue;\n const inc = pl.inc && typeof pl.inc === 'object' ? Object.keys(pl.inc as object) : null;\n events.push({ id: String(row.id), event: row.event, actor: row.actor, at: row.created_at, fields: inc });\n }\n cursor = body?.next_cursor ?? null;\n if (!cursor) break;\n }\n\n return Response.json({ subject: `${collection}:${itemId}`, requests, events, next_cursor: cursor, scanned, truncated: cursor !== null });\n },\n};\n",
19191
19806
  "console-api.ts": "// console-api.ts \u2014 EVERY STAFF READ, PLUS INTERNAL NOTES (a vxil function, http, end-user mode).\n//\n// POST /v1/fn/console-api { op, ... }\n//\n// op needs does\n// search accounts.read { q?, by?: 'email'|'external_ref'|'name', status?, plan?, sort?, cursor?, limit? }\n// account accounts.read { id } \u2192 one account\n// requests accounts.read { state?, account_id?, mine?, cursor?, limit? } \u2192 change requests, newest first\n// policies accounts.read {} \u2192 the approval policy rows\n// notes accounts.read { account_id, cursor? } \u2192 the account's internal notes\n// add_note notes.write { account_id, body } \u2192 one note, authored as the caller\n//\n// WHY ONE FUNCTION AND NOT A BROWSER KEY WITH cms:read. Sign-in is open to\n// everyone your identity provider lets through `allowedDomains`: the whole\n// company domain, not only the staff org. A browser key holding `cms:read` or\n// `comments:read` would let ANY of those people list every customer account\n// (e-mails included) and read or write internal notes, because collection\n// reads are not checked against the staff org. So the console's browser key\n// holds only `functions:invoke`, and every read comes through here, where the\n// orgs permission check runs first (revocation-grade: a removed role is\n// refused on the next call). A signed-in person outside the staff org gets\n// 403 on every op.\n//\n// The reads run with the CALLER's session, so field-level gates hold here too:\n// `risk_flags` reaches console-admins only, `billing_contact` approvers and\n// console-admins only.\n//\n// Lookups are index-served: e-mail = the unique `email` slot (exact); your\n// product's id = the indexed `external_ref` (exact); name = a `$startsWith`\n// prefix on the `name` slot (case-sensitive). With no `by`, a `q` holding an\n// '@' is an e-mail, otherwise a name prefix.\n//\n// This is also the seam for the day accounts move to YOUR database: keep the\n// request/response shape of `search` and `account` and swap their bodies for\n// one HTTPS call to your SQL endpoint (see examples/byo-db \u2014 a function + a\n// secret + one egress host). The console does not change.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_LIMIT = 100;\nconst MAX_NOTE = 4000;\nconst READ_OPS = new Set(['search', 'account', 'requests', 'policies', 'notes']);\n/** Sortable account columns: each one is on an index slot. A leading '-' sorts descending. */\nconst SORTS = new Set(['name', 'email', 'plan', 'status', 'credits', 'seats', 'created_at', 'last_active_at']);\nconst STATES = new Set(['pending', 'approved', 'rejected', 'expired', 'applied', 'failed']);\n\ninterface Payload {\n op?: unknown; q?: unknown; by?: unknown; status?: unknown; plan?: unknown; sort?: unknown; cursor?: unknown; limit?: unknown;\n id?: unknown; state?: unknown; account_id?: unknown; mine?: unknown; body?: unknown;\n}\ntype Env = HttpFunctionEnvelope<Payload>;\ntype Item = { item_id: string; data: Record<string, unknown> };\n\nconst hdrs = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\nconst fail = (status: number, error: string, extra: Record<string, unknown> = {}) =>\n Response.json({ error, ...extra }, { status });\nconst str = (v: unknown, max = 200): string | null =>\n typeof v === 'string' && v.trim() && v.length <= max ? v.trim() : null;\nconst itemId = (v: unknown): string | null =>\n typeof v === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : null;\n\nasync function requirePermission(base: string, orgs: string, userId: string, permission: string): Promise<string | Response> {\n const c = await fetch(`${base}/v1/orgs/session-claims?user_id=${encodeURIComponent(userId)}`, { headers: hdrs(orgs) });\n if (!c.ok) return fail(502, 'orgs_unavailable', { status: c.status });\n const orgId = ((await c.json()) as { data?: { org_id?: string | null } }).data?.org_id;\n if (!orgId) return fail(403, 'forbidden', { permission, message: 'you are not a member of the staff organization' });\n const r = await fetch(\n `${base}/v1/orgs/${encodeURIComponent(orgId)}/check?user_id=${encodeURIComponent(userId)}&permission=${encodeURIComponent(permission)}`,\n { headers: hdrs(orgs) },\n );\n if (!r.ok) return fail(502, 'orgs_unavailable', { status: r.status });\n const allowed = ((await r.json()) as { data?: { allowed?: boolean } }).data?.allowed === true;\n return allowed ? orgId : fail(403, 'forbidden', { permission, message: `you need the '${permission}' permission` });\n}\n\n/** One cms list call, answered as { items, next_cursor }. */\nasync function list(base: string, cms: string, collection: string, params: string[]): Promise<Response> {\n const r = await fetch(`${base}/v1/cms/items/${collection}?${params.filter(Boolean).join('&')}`, { headers: hdrs(cms) });\n if (!r.ok) return fail(r.status === 422 ? 422 : 502, 'read_failed', { detail: (await r.text()).slice(0, 300) });\n const page = ((await r.json()) as { data?: { items?: Item[]; next_cursor?: string | null } }).data;\n return Response.json({\n items: (page?.items ?? []).map((i) => ({ item_id: i.item_id, data: i.data })),\n next_cursor: page?.next_cursor ?? null,\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, orgs, comments } = env.scoped_jwts ?? {};\n const me = env.end_user?.id;\n if (!me) return fail(401, 'sign_in_required', { message: 'call this with a signed-in staff session' });\n if (!cms || !orgs || !comments) return fail(500, 'missing_scopes');\n\n const p = env.payload ?? {};\n const op = typeof p.op === 'string' ? p.op : 'search';\n if (!READ_OPS.has(op) && op !== 'add_note') {\n return fail(422, 'invalid_payload', { message: \"op must be one of search, account, requests, policies, notes, add_note\" });\n }\n const limit = Math.min(Math.max(Number.isInteger(p.limit) ? (p.limit as number) : 25, 1), MAX_LIMIT);\n const cursor = typeof p.cursor === 'string' && p.cursor ? `cursor=${encodeURIComponent(p.cursor)}` : '';\n\n // \u2500\u2500 the permission check comes before ANY read \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const orgId = await requirePermission(base, orgs, me, op === 'add_note' ? 'notes.write' : 'accounts.read');\n if (orgId instanceof Response) return orgId;\n\n switch (op) {\n case 'search': {\n const q = str(p.q);\n const by = p.by ?? (q?.includes('@') ? 'email' : 'name');\n if (by !== 'email' && by !== 'external_ref' && by !== 'name') {\n return fail(422, 'invalid_payload', { message: \"by must be 'email', 'external_ref' or 'name'\" });\n }\n const filter: Record<string, unknown> = {};\n if (q) {\n if (by === 'email') filter.email = q;\n else if (by === 'external_ref') filter.external_ref = q;\n else filter.name = { $startsWith: q };\n }\n const status = str(p.status, 32);\n const plan = str(p.plan, 64);\n if (status) filter.status = status;\n if (plan) filter.plan = plan;\n let sort = '';\n if (p.sort !== undefined) {\n if (typeof p.sort !== 'string' || !SORTS.has(p.sort.replace(/^-/, ''))) {\n return fail(422, 'invalid_payload', { message: `sort must be one of ${[...SORTS].join(', ')} (with an optional leading '-')` });\n }\n sort = `sort=${encodeURIComponent(p.sort)}`;\n }\n return list(base, cms, 'accounts', [Object.keys(filter).length ? `filter=${enc(filter)}` : '', sort, `limit=${limit}`, cursor]);\n }\n\n case 'account': {\n const id = itemId(p.id);\n if (!id) return fail(422, 'invalid_payload', { message: 'id must be an account id' });\n const r = await fetch(`${base}/v1/cms/items/accounts/${id}`, { headers: hdrs(cms) });\n if (r.status === 404) return fail(404, 'account_not_found');\n if (!r.ok) return fail(502, 'read_failed', { status: r.status });\n const i = ((await r.json()) as { data?: Item }).data;\n return Response.json({ item_id: i?.item_id ?? id, data: i?.data ?? {} });\n }\n\n case 'requests': {\n const filter: Record<string, unknown> = {};\n if (p.state !== undefined) {\n if (typeof p.state !== 'string' || !STATES.has(p.state)) return fail(422, 'invalid_payload', { message: 'state is not a request state' });\n filter.state = p.state;\n }\n if (p.account_id !== undefined) {\n const a = itemId(p.account_id);\n if (!a) return fail(422, 'invalid_payload', { message: 'account_id must be an account id' });\n filter.account_ref = a;\n }\n if (p.mine === true) filter.requested_by = me;\n return list(base, cms, 'change_requests', [\n Object.keys(filter).length ? `filter=${enc(filter)}` : '', 'sort=-requested_at', `limit=${limit}`, cursor,\n ]);\n }\n\n case 'policies':\n return list(base, cms, 'approval_policies', ['limit=100']);\n\n case 'notes':\n case 'add_note': {\n const accountId = itemId(p.account_id);\n if (!accountId) return fail(422, 'invalid_payload', { message: 'account_id must be an account id' });\n const topic = `accounts:${accountId}`;\n if (op === 'notes') {\n const r = await fetch(`${base}/v1/comments?topic=${encodeURIComponent(topic)}&limit=${limit}${cursor ? `&${cursor}` : ''}`, { headers: hdrs(comments) });\n if (!r.ok) return fail(502, 'read_failed', { status: r.status });\n const page = ((await r.json()) as { data?: { comments?: unknown[]; next_cursor?: string | null } }).data;\n return Response.json({ notes: page?.comments ?? [], next_cursor: page?.next_cursor ?? null });\n }\n const body = typeof p.body === 'string' ? p.body.trim() : '';\n if (!body || body.length > MAX_NOTE) return fail(422, 'invalid_payload', { message: `body is required (\u2264${MAX_NOTE} chars)` });\n // a note on an account that does not exist is refused, not orphaned\n const a = await fetch(`${base}/v1/cms/items/accounts/${accountId}`, { headers: hdrs(cms) });\n if (a.status === 404) return fail(404, 'account_not_found');\n if (!a.ok) return fail(502, 'read_failed', { status: a.status });\n // the author is the verified caller: a signed-in session cannot write as anyone else\n const w = await fetch(`${base}/v1/comments`, {\n method: 'POST',\n headers: hdrs(comments),\n body: JSON.stringify({ topic, body, author_id: me }),\n });\n if (!w.ok) return fail(w.status === 422 ? 422 : 502, 'note_failed', { status: w.status });\n const note = ((await w.json()) as { data?: unknown }).data;\n return Response.json({ note }, { status: 201 });\n }\n }\n return fail(422, 'invalid_payload');\n },\n};\n",
19192
19807
  "decide-change.ts": "// decide-change.ts \u2014 APPROVE OR REJECT ONE REQUEST (a vxil function, http, end-user mode).\n//\n// POST /v1/fn/decide-change { request_id, decision: 'approve' | 'reject', note? }\n//\n// 1. Reads the request; only a `pending` one can be decided (409 otherwise).\n// 2. Checks the caller holds the permission the kind's POLICY names\n// (`approver_permission`, default `changes.approve`) \u2014 403 naming it.\n// 3. Refuses the requester: 403 `self_approval`. The `no_self_approval` hook\n// refuses the same write again inside the database, so a bug here cannot\n// open the hole.\n// 4. Approve = ONE atomic batch, three writes:\n// change_requests/<id> if state == 'pending' \u2192 approved (decided_by = you)\n// accounts/<account> the stored, allow-listed patch ($inc credits, or fields)\n// change_requests/<id> if state == 'approved' \u2192 applied\n// All or nothing. Two approvers pressing at once: the second one's first\n// write finds the state no longer `pending` and gets a clean 409\n// `already_decided`; the account changes once.\n// If the ACCOUNT write is refused (a closed account, credits that would go\n// negative, a duplicate e-mail) the whole batch rolls back, and a second\n// small batch records the decision and the refusal: pending \u2192 approved \u2192\n// failed, with the error on the row.\n// 5. Reject = one conditional write, pending \u2192 rejected.\n// 6. Tells the requester (inbox + e-mail), keyed so a retry sends once.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst CONSOLE_URL = 'https://console.example.com';\nconst DEFAULT_EXPIRY_HOURS = 72;\n\ninterface Payload { request_id?: unknown; decision?: unknown; note?: unknown }\ntype Env = HttpFunctionEnvelope<Payload>;\ninterface ChangeRequest {\n kind: string; state: string; requested_by: string; amount?: number;\n account_ref: string; requested_at?: string; payload?: Record<string, unknown>; reason?: string;\n}\ninterface Policy { approver_permission?: string; expires_after_hours?: number | null }\ninterface BatchResult { op: string; status: number; data?: unknown; error?: { code?: string; message?: string } }\ntype Patch = { $inc: { credits: number } } | { data: Record<string, unknown> };\n\nconst hdrs = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\nconst fail = (status: number, error: string, extra: Record<string, unknown> = {}) =>\n Response.json({ error, ...extra }, { status });\n\n/** The SAME per-kind allow-list request-change enforces, re-applied to the\n * stored payload: what gets applied is only ever an allow-listed patch. */\nfunction patchFor(kind: string, amount: number, payload: Record<string, unknown>): Patch | string {\n const keys = Object.keys(payload);\n const extra = (allowed: string[]) => keys.filter((k) => !allowed.includes(k));\n switch (kind) {\n case 'credit_grant':\n return Number.isInteger(amount) && amount > 0 && !keys.length ? { $inc: { credits: amount } } : 'bad credit_grant';\n case 'plan_change':\n return typeof payload.plan === 'string' && !extra(['plan']).length ? { data: { plan: payload.plan } } : 'bad plan_change';\n case 'suspend':\n return keys.length ? 'bad suspend' : { data: { status: 'suspended' } };\n case 'reactivate':\n return keys.length ? 'bad reactivate' : { data: { status: 'active' } };\n case 'data_fix':\n return keys.length && !extra(['name', 'email', 'seats']).length ? { data: { ...payload } } : 'bad data_fix';\n default:\n return `unknown kind '${kind}'`;\n }\n}\n\nasync function requirePermission(base: string, orgs: string, userId: string, permission: string): Promise<string | Response> {\n const c = await fetch(`${base}/v1/orgs/session-claims?user_id=${encodeURIComponent(userId)}`, { headers: hdrs(orgs) });\n if (!c.ok) return fail(502, 'orgs_unavailable', { status: c.status });\n const orgId = ((await c.json()) as { data?: { org_id?: string | null } }).data?.org_id;\n if (!orgId) return fail(403, 'forbidden', { permission, message: 'you are not a member of the staff organization' });\n const r = await fetch(\n `${base}/v1/orgs/${encodeURIComponent(orgId)}/check?user_id=${encodeURIComponent(userId)}&permission=${encodeURIComponent(permission)}`,\n { headers: hdrs(orgs) },\n );\n if (!r.ok) return fail(502, 'orgs_unavailable', { status: r.status });\n const allowed = ((await r.json()) as { data?: { allowed?: boolean } }).data?.allowed === true;\n return allowed ? orgId : fail(403, 'forbidden', { permission, message: `you need the '${permission}' permission` });\n}\n\nasync function batch(base: string, cms: string, ops: unknown[]): Promise<{ ok: boolean; status: number; results: BatchResult[] }> {\n const r = await fetch(`${base}/v1/cms/batch`, {\n method: 'POST',\n headers: hdrs(cms),\n body: JSON.stringify({ atomic: true, ops }),\n });\n const out = ((await r.json().catch(() => ({}))) as { data?: { ok?: boolean; results?: BatchResult[] } }).data;\n return { ok: r.ok && out?.ok === true, status: r.status, results: out?.results ?? [] };\n}\n/** The first op that really failed (not the rolled-back / not-run ones). */\nconst firstFailure = (rs: BatchResult[]) =>\n rs.findIndex((x) => x.status >= 300 && x.error?.code !== 'rolled_back' && x.error?.code !== 'not_run');\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, orgs, notifications } = env.scoped_jwts ?? {};\n const me = env.end_user?.id;\n if (!me) return fail(401, 'sign_in_required', { message: 'call this with a signed-in staff session' });\n if (!cms || !orgs || !notifications) return fail(500, 'missing_scopes');\n\n const p = env.payload ?? {};\n const id = typeof p.request_id === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(p.request_id) ? p.request_id : null;\n const decision = p.decision === 'approve' || p.decision === 'reject' ? p.decision : null;\n const note = typeof p.note === 'string' ? p.note.slice(0, 2000) : null;\n if (!id || !decision) return fail(422, 'invalid_payload', { message: \"need { request_id, decision: 'approve' | 'reject', note? }\" });\n\n const got = await fetch(`${base}/v1/cms/items/change_requests/${id}`, { headers: hdrs(cms) });\n if (got.status === 404) return fail(404, 'not_found');\n if (!got.ok) return fail(502, 'cms_unavailable', { status: got.status });\n const cr = ((await got.json()) as { data?: { data?: ChangeRequest } }).data?.data;\n if (!cr) return fail(404, 'not_found');\n if (cr.state !== 'pending') return fail(409, 'already_decided', { state: cr.state });\n\n const pol = await fetch(`${base}/v1/cms/items/approval_policies?filter=${enc({ kind: cr.kind })}&limit=1`, { headers: hdrs(cms) });\n const policy: Policy = pol.ok\n ? (((await pol.json()) as { data?: { items?: Array<{ data?: Policy }> } }).data?.items?.[0]?.data ?? {})\n : {};\n const permission = policy.approver_permission || 'changes.approve';\n const orgId = await requirePermission(base, orgs, me, permission);\n if (orgId instanceof Response) return orgId;\n\n if (cr.requested_by === me) {\n return fail(403, 'self_approval', { message: 'you cannot decide a change you requested \u2014 ask another approver' });\n }\n const hours = policy.expires_after_hours ?? DEFAULT_EXPIRY_HOURS;\n if (hours > 0 && cr.requested_at && Date.parse(cr.requested_at) + hours * 3_600_000 < Date.now()) {\n return fail(409, 'expired', { message: `requests of this kind expire after ${hours} h; file a new one` });\n }\n\n const now = new Date().toISOString();\n const decided = { decided_by: me, decided_at: now, decision_note: note };\n let state: string;\n let error: string | undefined;\n\n if (decision === 'reject') {\n const r = await fetch(`${base}/v1/cms/items/change_requests/${id}`, {\n method: 'PATCH',\n headers: hdrs(cms),\n body: JSON.stringify({ if: { state: 'pending' }, data: { state: 'rejected', ...decided } }),\n });\n if (r.status === 409) return fail(409, 'already_decided');\n if (!r.ok) return fail(r.status === 422 ? 422 : 502, 'reject_refused', { detail: (await r.text()).slice(0, 300) });\n state = 'rejected';\n } else {\n const patch = patchFor(cr.kind, Number(cr.amount ?? 0), cr.payload ?? {});\n const approve = { op: 'patch', collection: 'change_requests', id, if: { state: 'pending' }, data: { state: 'approved', ...decided } };\n if (typeof patch === 'string') {\n // a stored payload outside the allow-list is never applied\n const rec = await batch(base, cms, [approve,\n { op: 'patch', collection: 'change_requests', id, if: { state: 'approved' }, data: { state: 'failed', error: patch } }]);\n if (!rec.ok) return fail(409, 'already_decided');\n state = 'failed';\n error = patch;\n } else {\n const res = await batch(base, cms, [\n approve,\n { op: 'patch', collection: 'accounts', id: cr.account_ref, ...patch },\n { op: 'patch', collection: 'change_requests', id, if: { state: 'approved' }, data: { state: 'applied' } },\n ]);\n if (res.ok) {\n state = 'applied';\n } else {\n const at = firstFailure(res.results);\n const why = res.results[at]?.error;\n if (at === 0) {\n // the guard on `pending` lost a race, or the hook refused the decision\n if (res.results[0]?.status === 409) return fail(409, 'already_decided');\n if (/self_approval/.test(why?.message ?? '')) return fail(403, 'self_approval');\n return fail(422, 'decision_refused', { detail: why?.message ?? why?.code });\n }\n if (at !== 1) return fail(502, 'apply_incomplete', { detail: why?.code ?? `http_${res.status}` });\n // the ACCOUNT refused the change: record the decision and the refusal\n error = `${why?.code ?? 'refused'}: ${(why?.message ?? '').slice(0, 300)}`;\n const rec = await batch(base, cms, [approve,\n { op: 'patch', collection: 'change_requests', id, if: { state: 'approved' }, data: { state: 'failed', error } }]);\n if (!rec.ok) return fail(409, 'already_decided');\n state = 'failed';\n }\n }\n }\n\n // \u2500\u2500 tell the requester, once per decision \u2500\u2500\u2500\u2500\u2500\u2500\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 await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { ...hdrs(notifications), 'idempotency-key': `decided:${id}` },\n body: JSON.stringify({\n user_id: cr.requested_by,\n template: 'transactional',\n channel: 'both',\n data: {\n subject: `Your ${cr.kind} request was ${state === 'applied' ? 'approved and applied' : state}`,\n paragraph: `Decided by ${me}${note ? ` \u2014 \"${note.slice(0, 300)}\"` : ''}.`\n + `${error ? ` It could not be applied: ${error}.` : ''} Details: ${CONSOLE_URL}/requests/${id}`,\n },\n }),\n }).catch(() => null);\n\n return Response.json({ request_id: id, state, ...(error ? { error } : {}) }, { status: state === 'failed' ? 422 : 200 });\n },\n};\n",
19193
19808
  "expire-pending.ts": "// expire-pending.ts \u2014 STALE REQUESTS EXPIRE (a vxil function, cron hourly).\n//\n// For every request kind: pending requests older than the kind's policy\n// `expires_after_hours` (72 h when the kind has no policy row; 0 = never)\n// become `expired`, and the requester is told once. A stale request never\n// lingers as something an approver could still apply weeks later.\n//\n// Each write is conditional (`if: { state: 'pending' }`): a request an approver\n// decides in the same instant keeps the approver's decision, and this tick\n// skips it. At most MAX_EXPIRE_PER_TICK rows per tick; `more: true` in the\n// result says the next tick continues (the rest are still pending and still\n// past their deadline, so they are still in the filter).\n\n// cron-walk: drains-filter \u2014 each expired request leaves the state=pending filter\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_EXPIRE_PER_TICK = 100;\nconst DEFAULT_EXPIRY_HOURS = 72;\nconst CONSOLE_URL = 'https://console.example.com';\nconst KINDS = ['credit_grant', 'plan_change', 'suspend', 'reactivate', 'data_fix'];\n\ninterface Policy { kind?: string; expires_after_hours?: number | null }\ninterface Row { item_id: string; data: { kind: string; requested_by: string; requested_at?: string } }\n\nconst hdrs = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as CronFunctionEnvelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const { cms, notifications } = env.scoped_jwts ?? {};\n if (!cms || !notifications) return Response.json({ error: 'missing_scopes' }, { status: 500 });\n\n const pr = await fetch(`${base}/v1/cms/items/approval_policies?limit=100`, { headers: hdrs(cms) });\n if (!pr.ok) return Response.json({ error: `policies ${pr.status}` }, { status: 502 });\n const policies = ((await pr.json()) as { data?: { items?: Array<{ data: Policy }> } }).data?.items ?? [];\n const hoursFor = (kind: string) => policies.find((x) => x.data.kind === kind)?.data.expires_after_hours ?? DEFAULT_EXPIRY_HOURS;\n\n const now = new Date();\n let expired = 0;\n let more = false;\n const failed: string[] = [];\n for (const kind of KINDS) {\n const hours = hoursFor(kind);\n if (!hours || hours <= 0) continue;\n const budget = MAX_EXPIRE_PER_TICK - expired;\n if (budget <= 0) { more = true; break; }\n const cutoff = new Date(now.getTime() - hours * 3_600_000).toISOString();\n const filter = { state: 'pending', kind, requested_at: { $lt: cutoff } };\n const r = await fetch(`${base}/v1/cms/items/change_requests?filter=${enc(filter)}&sort=requested_at&limit=${budget}`, {\n headers: hdrs(cms),\n });\n if (!r.ok) { failed.push(`${kind}: query ${r.status}`); continue; }\n const page = ((await r.json()) as { data?: { items?: Row[]; next_cursor?: string | null } }).data;\n const rows = page?.items ?? [];\n if (rows.length === budget && page?.next_cursor) more = true;\n\n for (const row of rows) {\n const w = await fetch(`${base}/v1/cms/items/change_requests/${row.item_id}`, {\n method: 'PATCH',\n headers: hdrs(cms),\n body: JSON.stringify({\n if: { state: 'pending' },\n data: { state: 'expired', decided_at: now.toISOString(), decision_note: `expired after ${hours} h without a decision` },\n }),\n });\n if (w.status === 409) continue; // decided in the meantime \u2014 the decision stands\n if (!w.ok) { failed.push(`${row.item_id}: ${w.status}`); continue; }\n expired++;\n await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { ...hdrs(notifications), 'idempotency-key': `expired:${row.item_id}` },\n body: JSON.stringify({\n user_id: row.data.requested_by,\n template: 'transactional',\n channel: 'both',\n data: {\n subject: `Your ${row.data.kind} request expired`,\n paragraph: `Nobody decided it within ${hours} h, so it was closed without being applied. `\n + `File it again if it is still needed: ${CONSOLE_URL}/requests/${row.item_id}`,\n },\n }),\n }).catch(() => null);\n }\n }\n return Response.json({ expired, more, failed });\n },\n};\n",
@@ -19306,13 +19921,12 @@ export default defineConfig({
19306
19921
  ],
19307
19922
  "hasFunctions": true,
19308
19923
  "byoKeys": [
19309
- "slack_webhook_url",
19310
- "vxil_read_key"
19924
+ "slack_webhook_url"
19311
19925
  ],
19312
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Alerts to Slack\" \u2014 failure events \u2192 one chat message per state crossing.\n//\n// There is no vxil \"connectors\" feature and no alert-routing engine, on purpose:\n// every feature already writes its lifecycle to your tenant's audit stream, and\n// a function can read that stream. This blueprint is DISTRIBUTION over that\n// spine \u2014 one cron function, one cms collection as its memory, one BYO webhook:\n// \u2022 cms \u2192 `alert_state`: the watermark + per-condition state (ok|stale|\n// broken|could_not_check) that makes alerts once-per-crossing\n// \u2022 functions \u2192 `alerts`: drains the audit stream past the watermark every\n// 5 minutes, matches an allow-list of failure events, posts\n// to Slack (or Discord) through the egress allowlist\n// A permanently-red condition therefore produces ONE message when it turns red,\n// ONE when it recovers, and (optionally) a reminder every REPEAT_AFTER_HOURS.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {},\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n // The function's memory. One row per alert KEY (e.g. `payments:ingest:stripe`,\n // `jobs:dead-letter:notifications.deliver`, `functions:checkout`) plus the\n // reserved `__cursor__` row that holds the audit-stream watermark.\n alert_state: {\n singular: 'alert_state',\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true }, // unique \u21D2 409 on a racing duplicate\n state: { type: 'string', indexSlot: 's2' }, // ok | stale | broken | could_not_check\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n event: { type: 'string' }, // the last audit event name seen for this key\n detail: { type: 'text' }, // last reason/error text (truncated)\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n last_event_id: { type: 'string' },\n last_alert_at: { type: 'datetime', indexSlot: 't1' },\n updated_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n },\n },\n\n functions: {\n // The whole blueprint is this one function. Cron every 5 minutes; also\n // invocable by hand (`vxil functions invoke alerts --data '{\"test\":true}'`)\n // to prove the webhook is wired without touching the watermark.\n alerts: {\n entry: './functions/alerts.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free plan: '*/15 * * * *' (its fastest function cron)\n scopes: ['cms:read', 'cms:write'],\n // Two BYO secrets, resolved per invocation and injected as env.secrets.<name>:\n // slack_webhook_url \u2014 the Slack incoming-webhook URL (or a Discord webhook,\n // or a Google Chat space webhook)\n // vxil_read_key \u2014 an API key of YOUR tenant holding ONLY `features:read`;\n // the audit stream is not reachable through the\n // function's scoped callback, so the drain reads it with\n // this least-privilege key instead\n secrets: ['secret:slack_webhook_url', 'secret:vxil_read_key'],\n // Deny-by-default egress: only the chat host (add 'discord.com' for Discord,\n // 'chat.googleapis.com' for a Google Chat space webhook).\n egressAllow: ['hooks.slack.com'],\n },\n },\n\n secrets: {\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord / Google Chat space webhook) URL' },\n vxil_read_key: { feature: 'functions', description: 'a vxil API key with only features:read \u2014 reads the audit stream' },\n },\n});\n",
19313
- "readme": "# Alerts to Slack (ops)\n\nFailure events from your backend \u2192 **one Slack (or Discord) message per state crossing**, with a\n\"resolved\" message when the condition clears. No connectors registry, no alert-routing feature \u2014 one\ncron function that drains your tenant's **audit stream** past a watermark, an allow-list of failure\nevents it cares about, and a `cms` collection as its memory so a permanently-red condition never\nspams the channel.\n\n```bash\nmkdir my-alerts && cd my-alerts\nvxil init --template alerts-to-slack\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\n# Free plan only: set the cron to '*/15 * * * *' in vxil.config.ts now (note below)\nvxil secrets set functions/slack_webhook_url # your webhook URL (hidden prompt)\nvxil login # keys mint needs a dashboard session, not a project key\nvxil keys mint --name alerts-audit --scopes audit:read --json | jq -r .api_key | vxil secrets set functions/vxil_read_key\nvxil push # collection + the `alerts` cron function\nvxil functions invoke alerts --data '{\"test\":true}' # posts a test message\n```\n\nThe mint line needs `jq`; it pipes the new key straight into the secret, so the key never shows in your\nterminal. (Without `jq`: run the mint with `--json`, copy `api_key`, and paste it at the hidden prompt of\n`vxil secrets set functions/vxil_read_key`. Or create the key on the dashboard's API keys page.)\n\n> **On the Free plan** (staging or development projects) the fastest function cron is every 15 minutes:\n> set `schedule: '*/15 * * * *'` in `vxil.config.ts` before `vxil push`, or the deploy answers\n> `402 plan_limit` (`cron_interval`). Paid plans run the 5-minute schedule as shipped.\n\n> **Plan note.** The `alerts` function deploys on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config's first push today). On a Free `production` project, `vxil push`\n> stops before it writes anything, naming the plan and the ways out: change the workload or upgrade\n> to Developer, or run `vxil push --skip-functions` to apply the collections and config without the\n> functions.\n\n**The two secrets** (references in the config, values only ever in the encrypted secret store):\n\n| Secret | What it is | Where it comes from |\n|---|---|---|\n| `slack_webhook_url` | a Slack *incoming webhook* URL (or a Discord webhook URL, or a Google Chat *space webhook* URL) | Slack \u2192 Apps \u2192 Incoming Webhooks; Discord \u2192 channel \u2192 Integrations \u2192 Webhooks; Google Chat \u2192 space \u2192 Apps & integrations \u2192 Webhooks |\n| `vxil_read_key` | an API key **of this project** holding **only `audit:read`** | the `vxil keys mint \u2026 --scopes audit:read` line above, or dashboard \u2192 API keys \u2192 create, tick `audit:read` and nothing else |\n\nWhy a key at all? The shipped function reads the audit stream (`GET /v1/audit/export`) with this\nsecret. That is a holdover, not a platform limit: a function that declares the `audit:read` scope can\nread `/v1/audit` and `/v1/audit/export` through its own scoped callback, with no stored key. Until you\nchange `functions/alerts.ts` to do that, keep the key as narrow as it can be: `audit:read` is read-only,\nreads the audit stream and nothing else, and is revocable in one click. The comments in\n`vxil.config.ts` still name `features:read`, which works too but also opens the whole-project export, so\nprefer `audit:read`.\nFor Discord, add `'discord.com'` to `egressAllow` \u2014 the function sends both Slack's `text` and Discord's\n`content` field, so one message body works on either.\nFor Google Chat, add `'chat.googleapis.com'` to `egressAllow` \u2014 a space webhook takes `text` alone, and the\nfunction sends only that field to that host.\n\n## What it does, every 5 minutes\n\n1. **Watermark.** Reads the `__cursor__` row in `alert_state`. On the very first run it stores the\n *newest* audit id and stops \u2014 history never becomes an alert storm.\n2. **Drain.** `GET /v1/audit/export?after_id=<cursor>&limit=500` (ascending NDJSON), up to 5 pages a tick.\n3. **Match.** Each row is looked up in `RULES` (the allow-list in `functions/alerts.ts`). Anything not\n listed is ignored. A match becomes `(key, state, level)` \u2014 e.g. a `payments.webhook_event.failed`\n row with `provider: stripe, state: broken` \u2192 key `payments:ingest:stripe`, state `broken`.\n4. **Compare + decide.** One `alert_state` row per key. Only a **crossing** posts:\n - not-ok while the row said ok (or no row yet) \u2192 \u{1F534}/\u{1F7E0} alert\n - ok while the row said not-ok \u2192 \u{1F7E2} \"RESOLVED\"\n - same state again \u2192 silence (a \u23F0 reminder after `REPEAT_AFTER_HOURS`, default 24; `0` disables)\n5. **Advance** the watermark with `If-Match` on the cursor row's version. Cron deliveries are\n at-least-once; if two ticks overlap, the first to move the watermark wins, and the `unique` key plus\n `If-Match` on every state row mean a racing run gets a 409 and stands down instead of double-posting.\n\n## The events it listens for\n\nEvery name in the table below is an audit event a vxil feature writes **today**. Keys are what collapse\nrepeats: ten dead letters of the same job are one key, one row, one alert.\n\n| Audit event | Level | Key (one row each) | State |\n|---|---|---|---|\n| `payments.webhook_event.failed` | from payload (`error`) | `payments:ingest:<provider>` | from payload (`broken`) |\n| `payments.webhook.rejected` | from payload (`warn`) | `payments:webhook-rejected:<provider>` | from payload (`broken`) |\n| `payments.grant.failed` | from payload (`error`) | `payments:grant:<credit_type>:<source>` | from payload (`broken`) |\n| `payments.subscription.past_due` | warn | `payments:subscription:<end_user_id>` | broken |\n| `payments.subscription.active` | info | `payments:subscription:<end_user_id>` | **ok** \u2192 posts RESOLVED |\n| `payments.charge.disputed` | warn | `payments:disputes:<provider>` | broken |\n| `job.dead_lettered` | error | `jobs:dead-letter:<job_name>` | broken |\n| `job.dead_letter_quota_exceeded` | error | `jobs:dead-letter-quota` | broken |\n| `job.generation.failed` | error | `jobs:generation:<error_class>` | broken |\n| `functions.quarantined` | error | `functions:<name>` | broken |\n| `functions.quarantine.cleared` | info | `functions:<name>` | **ok** \u2192 posts RESOLVED |\n| `functions.deploy.denied` | warn | `functions:deploy-denied` | broken |\n| `notifications.delivery.dead_lettered` | from payload (`error`) | `notifications:dead-letter:<template_id>` | from payload (`broken`) |\n| `webhooks.delivery.dead_lettered` | from payload (`error`) | `webhooks:delivery:<subscription_id>` | from payload (`broken`) |\n| `webhooks.delivery.recovered` | from payload (`info`) | `webhooks:delivery:<subscription_id>` | **ok** \u2192 posts RESOLVED |\n| `webhooks.inbound.rejected` | from payload (`warn`) | `webhooks:inbound:<source_id>:<reason>` | from payload (`broken`) |\n| `jobs.schedule.missed` | from payload (`warn`) | `jobs:schedule:<schedule_id>` | from payload (**`stale`** \u2014 late, not broken) |\n| `jobs.schedule.recovered` | from payload (`info`) | `jobs:schedule:<schedule_id>` | **ok** \u2192 posts RESOLVED |\n| `functions.run.failed` | from payload (`error`) | `functions:run:<name>` | from payload (`broken`) |\n| `functions.run.recovered` | from payload (`info`) | `functions:run:<name>` | **ok** \u2192 posts RESOLVED |\n\n**Levels differ by event, and the table respects that.** The payments failure events and every\nlifecycle failure event (the last nine rows) carry their own `level` and `state` in the payload, so those\nrules defer to the event. The older `job.*` and `functions.quarantine*` events supply both from the rule.\n\n**Five pairs close their own alerts.** `past_due` \u2192 `active`, `quarantined` \u2192 `quarantine.cleared`,\n`webhooks.delivery.dead_lettered` \u2192 `.recovered`, `jobs.schedule.missed` \u2192 `.recovered` and\n`functions.run.failed` \u2192 `.recovered` share a key, so a recovery posts RESOLVED with no extra rule. Events with no natural \"ok\" \u2014 a dead\nletter \u2014 stay red until the reminder window, which is what `REPEAT_AFTER_HOURS` is for.\n\n**The same failure can arrive twice, on purpose.** Notification sends and outbound webhook deliveries\nride the jobs substrate, so a dead letter also arrives as `job.dead_lettered` with `job_name` set to\n`notifications.deliver` / `webhooks.deliver`. The dedicated `*.dead_lettered` events carry the\nfeature-level context (template id, subscription id, target host) that the generic job row lacks; keep\nboth rules, or drop `job.dead_lettered` if you only want the feature-level view.\n\n**Own the table.** `RULES` is data: add an event name, decide its key and level, push.\n\n## The message\n\n```\n\u{1F534} [ERROR] payments ingest (stripe) \u2192 BROKEN \u2014 signature verification failed (at 2026-09-10T06:00:12Z)\n\u{1F534} [ERROR] job dead-lettered: notifications.deliver \u2192 BROKEN (at 2026-09-10T06:03:44Z)\n\u23F0 STILL BROKEN: function quarantined: checkout (since 2026-09-09T06:01:02Z)\n\u{1F7E2} RESOLVED: payments ingest (stripe) is back to ok (was broken)\n```\n\n## What to learn from this\n\n- **The audit stream is the event spine.** Every feature writes its lifecycle there; a webhook\n subscription fans it out, and a function can drain it. Nothing here needed a new primitive.\n- **Once-per-crossing suppression needs memory \u2014 a cms collection is that memory.** `unique` on `key`\n and `If-Match` on every write make it correct under the at-least-once cron, not just usually right.\n- **Least privilege has a shape.** The function talks to cms through its scoped callback, reads the\n audit stream with an `audit:read`-only key it holds as a secret (or, once you switch it over, through\n its callback with the `audit:read` scope), and can reach exactly one external host. Rotate either\n secret without a redeploy \u2014 refs resolve per invocation.\n\n**Latency:** a failure is posted within one cron interval (5 minutes; 15 on the Free plan) of the audit row landing.\n**Pairs with:** the **payments-heartbeat** blueprint (the silence detector that emits its own crossings)\nand the **push-notifications** blueprint.\n",
19926
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Alerts to Slack\" \u2014 failure events \u2192 one chat message per state crossing.\n//\n// There is no vxil \"connectors\" feature and no alert-routing engine, on purpose:\n// every feature already writes its lifecycle to your tenant's audit stream, and\n// a function can read that stream. This blueprint is DISTRIBUTION over that\n// spine \u2014 one cron function, one cms collection as its memory, one BYO webhook:\n// \u2022 cms \u2192 `alert_state`: the watermark + per-condition state (ok|stale|\n// broken|could_not_check) that makes alerts once-per-crossing\n// \u2022 functions \u2192 `alerts`: drains the audit stream past the watermark every\n// 5 minutes, matches an allow-list of failure events, posts\n// to Slack (or Discord) through the egress allowlist\n// A permanently-red condition therefore produces ONE message when it turns red,\n// ONE when it recovers, and (optionally) a reminder every REPEAT_AFTER_HOURS.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {},\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n // The function's memory. One row per alert KEY (e.g. `payments:ingest:stripe`,\n // `jobs:dead-letter:notifications.deliver`, `functions:checkout`) plus the\n // reserved `__cursor__` row that holds the audit-stream watermark.\n alert_state: {\n singular: 'alert_state',\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true }, // unique \u21D2 409 on a racing duplicate\n state: { type: 'string', indexSlot: 's2' }, // ok | stale | broken | could_not_check\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n event: { type: 'string' }, // the last audit event name seen for this key\n detail: { type: 'text' }, // last reason/error text (truncated)\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n last_event_id: { type: 'string' },\n last_alert_at: { type: 'datetime', indexSlot: 't1' },\n updated_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n },\n },\n\n functions: {\n // The whole blueprint is this one function. Cron every 5 minutes; also\n // invocable by hand (`vxil functions invoke alerts --data '{\"test\":true}'`)\n // to prove the webhook is wired without touching the watermark.\n alerts: {\n entry: './functions/alerts.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free plan: '*/15 * * * *' (its fastest function cron)\n // `audit:read` mints the function's confined control-plane token, which\n // reads the audit stream and nothing else \u2014 no API key stored as a secret.\n scopes: ['cms:read', 'cms:write', 'audit:read'],\n // One BYO secret, resolved per invocation and injected as env.secrets.<name>:\n // slack_webhook_url \u2014 the Slack incoming-webhook URL (or a Discord webhook,\n // or a Google Chat space webhook)\n secrets: ['secret:slack_webhook_url'],\n // Deny-by-default egress: only the chat host (add 'discord.com' for Discord,\n // 'chat.googleapis.com' for a Google Chat space webhook).\n egressAllow: ['hooks.slack.com'],\n },\n },\n\n secrets: {\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord / Google Chat space webhook) URL' },\n },\n});\n",
19927
+ "readme": "# Alerts to Slack (ops)\n\nFailure events from your backend \u2192 **one Slack (or Discord) message per state crossing**, with a\n\"resolved\" message when the condition clears. No connectors registry, no alert-routing feature \u2014 one\ncron function that drains your tenant's **audit stream** past a watermark, an allow-list of failure\nevents it cares about, and a `cms` collection as its memory so a permanently-red condition never\nspams the channel.\n\n```bash\nmkdir my-alerts && cd my-alerts\nvxil init --template alerts-to-slack\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\n# Free plan only: set the cron to '*/15 * * * *' in vxil.config.ts now (note below)\nvxil secrets set functions/slack_webhook_url # your webhook URL (hidden prompt)\nvxil push # collection + the `alerts` cron function\nvxil functions invoke alerts --data '{\"test\":true}' # posts a test message\n```\n\n> **On the Free plan** (staging or development projects) the fastest function cron is every 15 minutes:\n> set `schedule: '*/15 * * * *'` in `vxil.config.ts` before `vxil push`, or the deploy answers\n> `402 plan_limit` (`cron_interval`). Paid plans run the 5-minute schedule as shipped.\n\n> **Plan note.** The `alerts` function deploys on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config's first push today). On a Free `production` project, `vxil push`\n> stops before it writes anything, naming the plan and the ways out: change the workload or upgrade\n> to Developer, or run `vxil push --skip-functions` to apply the collections and config without the\n> functions.\n\n**The secret** (a reference in the config, its value only ever in the encrypted secret store):\n\n| Secret | What it is | Where it comes from |\n|---|---|---|\n| `slack_webhook_url` | a Slack *incoming webhook* URL (or a Discord webhook URL, or a Google Chat *space webhook* URL) | Slack \u2192 Apps \u2192 Incoming Webhooks; Discord \u2192 channel \u2192 Integrations \u2192 Webhooks; Google Chat \u2192 space \u2192 Apps & integrations \u2192 Webhooks |\n\nThe audit stream needs no key: the function declares `audit:read`, so its scoped callback carries a\nconfined token that reads `/v1/audit` and `/v1/audit/export` and nothing else.\nFor Discord, add `'discord.com'` to `egressAllow` \u2014 the function sends both Slack's `text` and Discord's\n`content` field, so one message body works on either.\nFor Google Chat, add `'chat.googleapis.com'` to `egressAllow` \u2014 a space webhook takes `text` alone, and the\nfunction sends only that field to that host.\n\n## What it does, every 5 minutes\n\n1. **Watermark.** Reads the `__cursor__` row in `alert_state`. On the very first run it stores the\n *newest* audit id and stops \u2014 history never becomes an alert storm.\n2. **Drain.** `GET /v1/audit/export?after_id=<cursor>&limit=500` (ascending NDJSON), up to 5 pages a tick.\n3. **Match.** Each row is looked up in `RULES` (the allow-list in `functions/alerts.ts`). Anything not\n listed is ignored. A match becomes `(key, state, level)` \u2014 e.g. a `payments.webhook_event.failed`\n row with `provider: stripe, state: broken` \u2192 key `payments:ingest:stripe`, state `broken`.\n4. **Compare + decide.** One `alert_state` row per key. Only a **crossing** posts:\n - not-ok while the row said ok (or no row yet) \u2192 \u{1F534}/\u{1F7E0} alert\n - ok while the row said not-ok \u2192 \u{1F7E2} \"RESOLVED\"\n - same state again \u2192 silence (a \u23F0 reminder after `REPEAT_AFTER_HOURS`, default 24; `0` disables)\n5. **Advance** the watermark with `If-Match` on the cursor row's version. Cron deliveries are\n at-least-once; if two ticks overlap, the first to move the watermark wins, and the `unique` key plus\n `If-Match` on every state row mean a racing run gets a 409 and stands down instead of double-posting.\n\n## The events it listens for\n\nEvery name in the table below is an audit event a vxil feature writes **today**. Keys are what collapse\nrepeats: ten dead letters of the same job are one key, one row, one alert.\n\n| Audit event | Level | Key (one row each) | State |\n|---|---|---|---|\n| `payments.webhook_event.failed` | from payload (`error`) | `payments:ingest:<provider>` | from payload (`broken`) |\n| `payments.webhook.rejected` | from payload (`warn`) | `payments:webhook-rejected:<provider>` | from payload (`broken`) |\n| `payments.grant.failed` | from payload (`error`) | `payments:grant:<credit_type>:<source>` | from payload (`broken`) |\n| `payments.subscription.past_due` | warn | `payments:subscription:<end_user_id>` | broken |\n| `payments.subscription.active` | info | `payments:subscription:<end_user_id>` | **ok** \u2192 posts RESOLVED |\n| `payments.charge.disputed` | warn | `payments:disputes:<provider>` | broken |\n| `job.dead_lettered` | error | `jobs:dead-letter:<job_name>` | broken |\n| `job.dead_letter_quota_exceeded` | error | `jobs:dead-letter-quota` | broken |\n| `job.generation.failed` | error | `jobs:generation:<error_class>` | broken |\n| `functions.quarantined` | error | `functions:<name>` | broken |\n| `functions.quarantine.cleared` | info | `functions:<name>` | **ok** \u2192 posts RESOLVED |\n| `functions.deploy.denied` | warn | `functions:deploy-denied` | broken |\n| `notifications.delivery.dead_lettered` | from payload (`error`) | `notifications:dead-letter:<template_id>` | from payload (`broken`) |\n| `webhooks.delivery.dead_lettered` | from payload (`error`) | `webhooks:delivery:<subscription_id>` | from payload (`broken`) |\n| `webhooks.delivery.recovered` | from payload (`info`) | `webhooks:delivery:<subscription_id>` | **ok** \u2192 posts RESOLVED |\n| `webhooks.inbound.rejected` | from payload (`warn`) | `webhooks:inbound:<source_id>:<reason>` | from payload (`broken`) |\n| `jobs.schedule.missed` | from payload (`warn`) | `jobs:schedule:<schedule_id>` | from payload (**`stale`** \u2014 late, not broken) |\n| `jobs.schedule.recovered` | from payload (`info`) | `jobs:schedule:<schedule_id>` | **ok** \u2192 posts RESOLVED |\n| `functions.run.failed` | from payload (`error`) | `functions:run:<name>` | from payload (`broken`) |\n| `functions.run.recovered` | from payload (`info`) | `functions:run:<name>` | **ok** \u2192 posts RESOLVED |\n\n**Levels differ by event, and the table respects that.** The payments failure events and every\nlifecycle failure event (the last nine rows) carry their own `level` and `state` in the payload, so those\nrules defer to the event. The older `job.*` and `functions.quarantine*` events supply both from the rule.\n\n**Five pairs close their own alerts.** `past_due` \u2192 `active`, `quarantined` \u2192 `quarantine.cleared`,\n`webhooks.delivery.dead_lettered` \u2192 `.recovered`, `jobs.schedule.missed` \u2192 `.recovered` and\n`functions.run.failed` \u2192 `.recovered` share a key, so a recovery posts RESOLVED with no extra rule. Events with no natural \"ok\" \u2014 a dead\nletter \u2014 stay red until the reminder window, which is what `REPEAT_AFTER_HOURS` is for.\n\n**The same failure can arrive twice, on purpose.** Notification sends and outbound webhook deliveries\nride the jobs substrate, so a dead letter also arrives as `job.dead_lettered` with `job_name` set to\n`notifications.deliver` / `webhooks.deliver`. The dedicated `*.dead_lettered` events carry the\nfeature-level context (template id, subscription id, target host) that the generic job row lacks; keep\nboth rules, or drop `job.dead_lettered` if you only want the feature-level view.\n\n**Own the table.** `RULES` is data: add an event name, decide its key and level, push.\n\n## The message\n\n```\n\u{1F534} [ERROR] payments ingest (stripe) \u2192 BROKEN \u2014 signature verification failed (at 2026-09-10T06:00:12Z)\n\u{1F534} [ERROR] job dead-lettered: notifications.deliver \u2192 BROKEN (at 2026-09-10T06:03:44Z)\n\u23F0 STILL BROKEN: function quarantined: checkout (since 2026-09-09T06:01:02Z)\n\u{1F7E2} RESOLVED: payments ingest (stripe) is back to ok (was broken)\n```\n\n## What to learn from this\n\n- **The audit stream is the event spine.** Every feature writes its lifecycle there; a webhook\n subscription fans it out, and a function can drain it. Nothing here needed a new primitive.\n- **Once-per-crossing suppression needs memory \u2014 a cms collection is that memory.** `unique` on `key`\n and `If-Match` on every write make it correct under the at-least-once cron, not just usually right.\n- **Least privilege has a shape.** The function talks to cms through its scoped callback, reads the\n audit stream with an `audit:read`-only key it holds as a secret (or, once you switch it over, through\n its callback with the `audit:read` scope), and can reach exactly one external host. Rotate either\n secret without a redeploy \u2014 refs resolve per invocation.\n\n**Latency:** a failure is posted within one cron interval (5 minutes; 15 on the Free plan) of the audit row landing.\n**Pairs with:** the **payments-heartbeat** blueprint (the silence detector that emits its own crossings)\nand the **push-notifications** blueprint.\n",
19314
19928
  "functions": {
19315
- "alerts.ts": "// alerts.ts \u2014 FAILURE EVENTS \u2192 ONE CHAT MESSAGE PER STATE CROSSING (a vxil function).\n//\n// Trigger: cron `*/5 * * * *`. Each run:\n// 1. reads the watermark (the `__cursor__` row in `alert_state`; first run = \"now\",\n// so history never produces an alert storm),\n// 2. drains the tenant audit stream past it \u2014 `GET /v1/audit/export?after_id=\u2026`\n// (ascending, NDJSON) with the `vxil_read_key` secret (a key holding ONLY\n// `features:read`; the audit stream is not on the scoped callback),\n// 3. matches each row against RULES (the allow-list below) and turns it into a\n// (key, state, level) \u2014 e.g. `payments:ingest:stripe` \u2192 `broken`,\n// 4. compares with the stored state for that key (cms, one row per key, `unique`\n// key \u21D2 a racing run gets a 409 and stands down) and posts ONLY on a crossing:\n// ok\u2192red = alert, red\u2192ok = \"resolved\", red\u2192red = silence (or a reminder after\n// REPEAT_AFTER_HOURS),\n// 5. advances the watermark (If-Match on the cursor row's version \u2014 a concurrent\n// run that already advanced it wins; jobs may deliver a cron tick twice).\n//\n// Manual check: `vxil functions invoke alerts --data '{\"test\":true}'` posts a test\n// message and touches nothing else.\n//\n// Tune the constants; own the RULES table \u2014 it is data, not a routing engine.\n\n// cron-walk: persisted-cursor \u2014 the audit watermark lives in the `__cursor__` row and advances with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst REPEAT_AFTER_HOURS = 24; // remind about a still-red condition this often; 0 = never\nconst MAX_PAGES_PER_RUN = 5; // \xD7 500 audit rows \u2014 bounds one cron tick\nconst MAX_POSTS_PER_RUN = 20; // a burst of distinct failures collapses into one summary line\nconst DETAIL_MAX = 240;\n\ntype Level = 'info' | 'warn' | 'error';\ntype State = 'ok' | 'stale' | 'broken' | 'could_not_check';\ntype Payload = Record<string, unknown>;\n\ninterface Rule {\n /** A literal level, or 'payload' to take `payload.level` (the payments failure\n * events carry one; the jobs/functions ones do not). */\n level: Level | 'payload';\n key: (p: Payload) => string; // one state row per key \u2014 this is what suppresses repeats\n title: (p: Payload) => string;\n /** A literal state, or omitted to take `payload.state` (falling back to 'broken'). */\n state?: State;\n}\n\nconst str = (v: unknown, fallback = 'unknown') => (typeof v === 'string' && v ? v : fallback);\n\n/** THE ALLOW-LIST. Every name below is an audit event a vxil feature writes;\n * anything not listed is ignored by the drain. The KEY is what collapses\n * repeats: ten dead letters of the same job are one key, one row, one alert.\n *\n * Every name below is emitted today (2026-09-10). A rule for an event nobody\n * emits would simply never match \u2014 so if you add one, verify the emitter first.\n */\nconst RULES: Record<string, Rule> = {\n // \u2500\u2500 payments \u2014 the money path. A silent failure here is lost revenue. \u2500\u2500\u2500\u2500\u2500\u2500\n // These three carry `level` (info|warn|error) and `state` (ok|stale|broken|\n // could_not_check) in their payload, so the rule defers to the event.\n 'payments.webhook_event.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:ingest:${str(p.provider, 'all')}`,\n title: (p) => `payments ingest (${str(p.provider, 'all providers')})`,\n },\n 'payments.webhook.rejected': {\n level: 'payload', // emitted as warn/broken\n key: (p) => `payments:webhook-rejected:${str(p.provider)}`,\n title: (p) => `payments webhook rejected (${str(p.provider)})`,\n },\n // NOTE: grant.failed carries NO `provider` \u2014 it is keyed on the credit type\n // and the grant source instead (end_user_id/credit_type/amount/grant_key/\n // source/error). Keying on a field an event does not carry would collapse\n // every provider into one bucket named \"unknown\".\n 'payments.grant.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:grant:${str(p.credit_type, 'credits')}:${str(p.source, 'webhook')}`,\n title: (p) => `entitlement grant failed (${str(p.credit_type, 'credits')} via ${str(p.source, 'webhook')})`,\n },\n // Dunning. `past_due` is the red crossing and `active` is its RESOLVED twin \u2014\n // the same key, so a recovered subscription closes its own alert. Both are\n // emitted at level info, so the rule forces the level it wants.\n 'payments.subscription.past_due': {\n level: 'warn',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'broken',\n },\n 'payments.subscription.active': {\n level: 'info',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'ok',\n },\n // A dispute is a chargeback in progress \u2014 money already collected, now at risk.\n 'payments.charge.disputed': {\n level: 'warn',\n key: (p) => `payments:disputes:${str(p.provider)}`,\n title: (p) => `charge disputed (${str(p.provider)})`,\n state: 'broken',\n },\n\n // \u2500\u2500 jobs \u2014 the delivery substrate. Notification and outbound-webhook sends\n // ride jobs, so THEIR dead letters arrive here too, under `job_name`\n // ('notifications.deliver', 'webhooks.deliver', 'fn-cron:<name>', \u2026).\n // These payloads carry run_id/attempt/job_name and NO level or state, so\n // the rule supplies both. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n 'job.dead_lettered': {\n level: 'error',\n key: (p) => `jobs:dead-letter:${str(p.job_name)}`,\n title: (p) => `job dead-lettered: ${str(p.job_name)}`,\n state: 'broken',\n },\n 'job.dead_letter_quota_exceeded': {\n level: 'error',\n key: () => 'jobs:dead-letter-quota',\n title: () => 'daily dead-letter quota exceeded',\n state: 'broken',\n },\n 'job.generation.failed': {\n level: 'error',\n key: (p) => `jobs:generation:${str(p.error_class, 'failed')}`,\n title: (p) => `generation job failed (${str(p.error_class, 'unclassified')})`,\n state: 'broken',\n },\n\n // \u2500\u2500 functions \u2014 the breaker. Consecutive failures quarantine a function;\n // clearing the quarantine is the ok crossing on the SAME key, so this pair\n // produces exactly one \u{1F534} and one \u{1F7E2}. Payload: name (+ consecutive_failures).\n 'functions.quarantined': {\n level: 'error',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'broken',\n },\n 'functions.quarantine.cleared': {\n level: 'info',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'ok',\n },\n 'functions.deploy.denied': {\n level: 'warn',\n key: () => 'functions:deploy-denied',\n title: (p) => `function deploy denied (needs the ${str(p.required_tier)} tier)`,\n state: 'broken',\n },\n\n // \u2500\u2500 The lifecycle failure events (live since 2026-09-10). Every one of these\n // carries `level` + `state` in its payload (the platform's failure-event\n // vocabulary gate enforces it), so the rules defer to the event. Each\n // failure/recovery pair shares a key, so a recovery posts RESOLVED. \u2500\u2500\u2500\u2500\u2500\u2500\n 'notifications.delivery.dead_lettered': {\n level: 'payload', // emitted as error/broken; payload: delivery_id, template_id, error_class, attempts\n key: (p) => `notifications:dead-letter:${str(p.template_id, 'all')}`,\n title: (p) => `notification delivery dead-lettered (${str(p.template_id, 'all templates')})`,\n },\n 'webhooks.delivery.dead_lettered': {\n level: 'payload', // error/broken; payload: subscription_id, target_host, attempts, error_class\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.delivery.recovered': {\n level: 'payload', // info/ok \u2014 same key as the dead-letter \u2192 RESOLVED\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.inbound.rejected': {\n level: 'payload', // warn/broken; payload: source_id, provider, reason, fingerprint (never the body)\n key: (p) => `webhooks:inbound:${str(p.source_id)}:${str(p.reason)}`,\n title: (p) => `inbound webhook rejected (${str(p.provider, 'source')} ${str(p.source_id)}: ${str(p.reason)})`,\n },\n 'jobs.schedule.missed': {\n level: 'payload', // warn/STALE \u2014 a late tick is stale, not broken; payload: schedule_id, job_name, late_seconds\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n 'jobs.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED on the next on-time fire\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n // A function's cron trigger that produced NO run for 2\xD7 its interval \u2014 the\n // platform's silent-schedule watch (error/stale), and the recovery on the same\n // key. Payload: function, schedule_id, cron, last_run_at, expected_by.\n 'functions.schedule.missed': {\n level: 'payload',\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))} (last run ${str(p.last_run_at, 'never')})`,\n },\n 'functions.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED when a run appears again\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))}`,\n },\n 'functions.run.failed': {\n level: 'payload', // error/broken; payload: name, trigger, run_id, error_class\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n 'functions.run.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n};\n\n// the schedule tick, or a hand invoke (`POST /v1/fn/alerts`) carrying `{ test: true }`\ntype Envelope = CronFunctionEnvelope | HttpFunctionEnvelope<{ test?: boolean }>;\ninterface AuditRow { id: string | number; event: string; payload?: Payload; created_at?: string }\ninterface StateData {\n key: string; state?: State; level?: Level; event?: string; detail?: string;\n cursor?: string; last_event_id?: string; last_alert_at?: string; updated_at?: string;\n}\ninterface Item { item_id: string; version?: number; data: StateData }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const hook = env.secrets?.slack_webhook_url;\n const readKey = env.secrets?.vxil_read_key;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n if (!hook || !readKey) return Response.json({ error: 'missing secrets' }, { status: 409 });\n\n // Manual wiring check \u2014 posts, touches nothing else.\n if (env.payload?.test) {\n const r = await post(hook, `\u{1F7E2} [INFO] alerts-to-slack is wired (test message)`);\n return Response.json({ test: true, posted: r.ok, status: r.status });\n }\n\n const store = new Store(base, cms);\n\n // 1. the watermark (first run: start at the newest audit id \u2014 no history storm)\n const cursorRow = await store.byKey('__cursor__');\n if (!cursorRow) {\n const head = await newestAuditId(base, readKey);\n await store.create({ key: '__cursor__', cursor: head, updated_at: iso() });\n return Response.json({ initialized: true, cursor: head });\n }\n let cursor = cursorRow.data.cursor ?? '0';\n const cursorVersion = cursorRow.version;\n\n // 2\u20134. drain + evaluate\n const posts: string[] = [];\n let scanned = 0;\n let matched = 0;\n for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {\n const { rows, next } = await exportAudit(base, readKey, cursor);\n for (const row of rows) {\n scanned++;\n const rule = RULES[row.event];\n if (rule) {\n matched++;\n const msg = await evaluate(store, rule, row);\n if (msg) posts.push(msg);\n }\n cursor = String(row.id);\n }\n if (!next) break;\n cursor = next;\n }\n\n // deliver (bounded)\n let posted = 0;\n const lines = posts.slice(0, MAX_POSTS_PER_RUN);\n if (posts.length > MAX_POSTS_PER_RUN) lines.push(`\u2026 and ${posts.length - MAX_POSTS_PER_RUN} more state changes this tick`);\n for (const text of lines) {\n const r = await post(hook, text);\n if (r.ok) posted++;\n }\n\n // 5. advance the watermark \u2014 If-Match: a concurrent run that already moved it wins\n const advanced = await store.patch(cursorRow.item_id, cursorVersion, { cursor, updated_at: iso() });\n\n return Response.json({ scanned, matched, alerts: posts.length, posted, cursor, advanced });\n },\n};\n\n/** Decide, for one matched audit row, whether a message is due; persist the state. */\nasync function evaluate(store: Store, rule: Rule, row: AuditRow): Promise<string | null> {\n const p = row.payload ?? {};\n const key = rule.key(p);\n const next: State = rule.state ?? (isState(p.state) ? p.state : 'broken');\n const level: Level = rule.level === 'payload' ? (isLevel(p.level) ? p.level : 'error') : rule.level;\n const title = rule.title(p);\n const detail = str(p.reason ?? p.error ?? p.message ?? p.last_error_msg, '').slice(0, DETAIL_MAX);\n const now = iso();\n const eventId = String(row.id);\n const base: StateData = { key, state: next, level, event: row.event, detail, last_event_id: eventId, updated_at: now };\n\n const current = await store.byKey(key);\n if (!current) {\n // First sighting. A green first sighting is remembered silently \u2014 nothing was red.\n if (next === 'ok') { await store.create(base); return null; }\n const created = await store.create({ ...base, last_alert_at: now });\n return created ? format(level, next, title, detail, row.created_at) : null; // 409 = a racing run owns it\n }\n const prev = current.data.state ?? 'ok';\n if (prev !== next) {\n // A CROSSING. If-Match: exactly one concurrent run wins the transition.\n const ok = await store.patch(current.item_id, current.version, { ...base, last_alert_at: now });\n if (!ok) return null;\n return next === 'ok'\n ? `\u{1F7E2} RESOLVED: ${title} is back to ok (was ${prev})`\n : format(level, next, title, detail, row.created_at);\n }\n // Same state. Red stays quiet \u2014 unless a reminder is due.\n if (next !== 'ok' && REPEAT_AFTER_HOURS > 0) {\n const last = Date.parse(current.data.last_alert_at ?? '') || 0;\n if (Date.now() - last >= REPEAT_AFTER_HOURS * 3600e3) {\n const ok = await store.patch(current.item_id, current.version, { ...base, last_alert_at: now });\n return ok ? `\u23F0 STILL ${next.toUpperCase()}: ${title} (since ${current.data.last_alert_at ?? '?'})` : null;\n }\n }\n await store.patch(current.item_id, current.version, base); // keep detail/last_event_id fresh, no post\n return null;\n}\n\nfunction format(level: Level, state: State, title: string, detail: string, at?: string): string {\n const dot = level === 'error' ? '\u{1F534}' : level === 'warn' ? '\u{1F7E0}' : '\u{1F535}';\n return `${dot} [${level.toUpperCase()}] ${title} \u2192 ${state.toUpperCase()}${detail ? ` \u2014 ${detail}` : ''}${at ? ` (at ${at})` : ''}`;\n}\n\n// \u2500\u2500 the audit stream (ascending NDJSON export; `x-vxil-next-after-id` continues it) \u2500\u2500\nasync function exportAudit(base: string, key: string, afterId: string): Promise<{ rows: AuditRow[]; next: string | null }> {\n const res = await fetch(`${base}/v1/audit/export?after_id=${encodeURIComponent(afterId)}&limit=500`, {\n headers: { authorization: `Bearer ${key}` },\n });\n if (!res.ok) throw new Error(`audit export ${res.status}`);\n const rows = (await res.text()).split('\\n').filter(Boolean).map((l) => JSON.parse(l) as AuditRow);\n return { rows, next: res.headers.get('x-vxil-next-after-id') };\n}\nasync function newestAuditId(base: string, key: string): Promise<string> {\n const res = await fetch(`${base}/v1/audit?limit=1`, { headers: { authorization: `Bearer ${key}` } });\n if (!res.ok) throw new Error(`audit list ${res.status}`);\n const body = (await res.json()) as { data?: { events?: AuditRow[] } };\n return String(body.data?.events?.[0]?.id ?? '0');\n}\n\n// \u2500\u2500 the cms state store (the REST envelope: { data: { items:[{item_id, version, data}] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() { return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' }; }\n async byKey(key: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${this.base}/v1/cms/items/alert_state?filter=${filter}&limit=1`, { headers: this.h() });\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 (unique key already claimed by a concurrent run) */\n async create(data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/alert_state`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ data, status: 'published' }),\n });\n return res.ok;\n }\n /** false on 409 version_conflict (another run transitioned this key first) */\n async patch(itemId: string, version: number | undefined, data: Partial<StateData>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/alert_state/${itemId}`, {\n method: 'PATCH',\n headers: { ...this.h(), ...(version !== undefined ? { 'if-match': String(version) } : {}) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n\n// \u2500\u2500 the chat webhook: `text` is Slack's field, `content` is Discord's \u2014 send both,\n// EXCEPT to a Google Chat space webhook (chat.googleapis.com), which takes\n// `{ text }` alone \u2014 an unknown field can be rejected there, so it is dropped.\nasync function post(url: string, text: string): Promise<{ ok: boolean; status: number }> {\n let host = '';\n try { host = new URL(url).hostname; } catch { /* unparseable \u2192 the generic body below */ }\n const body = host === 'chat.googleapis.com' ? { text } : { text, content: text };\n const res = await fetch(url, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n }).catch(() => null);\n return { ok: Boolean(res?.ok), status: res?.status ?? 0 };\n}\n\nconst iso = () => new Date().toISOString();\nconst isState = (v: unknown): v is State => v === 'ok' || v === 'stale' || v === 'broken' || v === 'could_not_check';\nconst isLevel = (v: unknown): v is Level => v === 'info' || v === 'warn' || v === 'error';\n"
19929
+ "alerts.ts": "// alerts.ts \u2014 FAILURE EVENTS \u2192 ONE CHAT MESSAGE PER STATE CROSSING (a vxil function).\n//\n// Trigger: cron `*/5 * * * *`. Each run:\n// 1. reads the watermark (the `__cursor__` row in `alert_state`; first run = \"now\",\n// so history never produces an alert storm),\n// 2. drains the tenant audit stream past it \u2014 `GET /v1/audit/export?after_id=\u2026`\n// (ascending, NDJSON) with the function's confined control-plane token\n// (the declared `audit:read` scope; no stored key),\n// 3. matches each row against RULES (the allow-list below) and turns it into a\n// (key, state, level) \u2014 e.g. `payments:ingest:stripe` \u2192 `broken`,\n// 4. compares with the stored state for that key (cms, one row per key, `unique`\n// key \u21D2 a racing run gets a 409 and stands down) and posts ONLY on a crossing:\n// ok\u2192red = alert, red\u2192ok = \"resolved\", red\u2192red = silence (or a reminder after\n// REPEAT_AFTER_HOURS),\n// 5. advances the watermark (If-Match on the cursor row's version \u2014 a concurrent\n// run that already advanced it wins; jobs may deliver a cron tick twice).\n//\n// Manual check: `vxil functions invoke alerts --data '{\"test\":true}'` posts a test\n// message and touches nothing else.\n//\n// Tune the constants; own the RULES table \u2014 it is data, not a routing engine.\n\n// cron-walk: persisted-cursor \u2014 the audit watermark lives in the `__cursor__` row and advances with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst REPEAT_AFTER_HOURS = 24; // remind about a still-red condition this often; 0 = never\nconst MAX_PAGES_PER_RUN = 5; // \xD7 500 audit rows \u2014 bounds one cron tick\nconst MAX_POSTS_PER_RUN = 20; // a burst of distinct failures collapses into one summary line\nconst DETAIL_MAX = 240;\n\ntype Level = 'info' | 'warn' | 'error';\ntype State = 'ok' | 'stale' | 'broken' | 'could_not_check';\ntype Payload = Record<string, unknown>;\n\ninterface Rule {\n /** A literal level, or 'payload' to take `payload.level` (the payments failure\n * events carry one; the jobs/functions ones do not). */\n level: Level | 'payload';\n key: (p: Payload) => string; // one state row per key \u2014 this is what suppresses repeats\n title: (p: Payload) => string;\n /** A literal state, or omitted to take `payload.state` (falling back to 'broken'). */\n state?: State;\n}\n\nconst str = (v: unknown, fallback = 'unknown') => (typeof v === 'string' && v ? v : fallback);\n\n/** THE ALLOW-LIST. Every name below is an audit event a vxil feature writes;\n * anything not listed is ignored by the drain. The KEY is what collapses\n * repeats: ten dead letters of the same job are one key, one row, one alert.\n *\n * Every name below is emitted today (2026-09-10). A rule for an event nobody\n * emits would simply never match \u2014 so if you add one, verify the emitter first.\n */\nconst RULES: Record<string, Rule> = {\n // \u2500\u2500 payments \u2014 the money path. A silent failure here is lost revenue. \u2500\u2500\u2500\u2500\u2500\u2500\n // These three carry `level` (info|warn|error) and `state` (ok|stale|broken|\n // could_not_check) in their payload, so the rule defers to the event.\n 'payments.webhook_event.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:ingest:${str(p.provider, 'all')}`,\n title: (p) => `payments ingest (${str(p.provider, 'all providers')})`,\n },\n 'payments.webhook.rejected': {\n level: 'payload', // emitted as warn/broken\n key: (p) => `payments:webhook-rejected:${str(p.provider)}`,\n title: (p) => `payments webhook rejected (${str(p.provider)})`,\n },\n // NOTE: grant.failed carries NO `provider` \u2014 it is keyed on the credit type\n // and the grant source instead (end_user_id/credit_type/amount/grant_key/\n // source/error). Keying on a field an event does not carry would collapse\n // every provider into one bucket named \"unknown\".\n 'payments.grant.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:grant:${str(p.credit_type, 'credits')}:${str(p.source, 'webhook')}`,\n title: (p) => `entitlement grant failed (${str(p.credit_type, 'credits')} via ${str(p.source, 'webhook')})`,\n },\n // Dunning. `past_due` is the red crossing and `active` is its RESOLVED twin \u2014\n // the same key, so a recovered subscription closes its own alert. Both are\n // emitted at level info, so the rule forces the level it wants.\n 'payments.subscription.past_due': {\n level: 'warn',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'broken',\n },\n 'payments.subscription.active': {\n level: 'info',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'ok',\n },\n // A dispute is a chargeback in progress \u2014 money already collected, now at risk.\n 'payments.charge.disputed': {\n level: 'warn',\n key: (p) => `payments:disputes:${str(p.provider)}`,\n title: (p) => `charge disputed (${str(p.provider)})`,\n state: 'broken',\n },\n\n // \u2500\u2500 jobs \u2014 the delivery substrate. Notification and outbound-webhook sends\n // ride jobs, so THEIR dead letters arrive here too, under `job_name`\n // ('notifications.deliver', 'webhooks.deliver', 'fn-cron:<name>', \u2026).\n // These payloads carry run_id/attempt/job_name and NO level or state, so\n // the rule supplies both. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n 'job.dead_lettered': {\n level: 'error',\n key: (p) => `jobs:dead-letter:${str(p.job_name)}`,\n title: (p) => `job dead-lettered: ${str(p.job_name)}`,\n state: 'broken',\n },\n 'job.dead_letter_quota_exceeded': {\n level: 'error',\n key: () => 'jobs:dead-letter-quota',\n title: () => 'daily dead-letter quota exceeded',\n state: 'broken',\n },\n 'job.generation.failed': {\n level: 'error',\n key: (p) => `jobs:generation:${str(p.error_class, 'failed')}`,\n title: (p) => `generation job failed (${str(p.error_class, 'unclassified')})`,\n state: 'broken',\n },\n\n // \u2500\u2500 functions \u2014 the breaker. Consecutive failures quarantine a function;\n // clearing the quarantine is the ok crossing on the SAME key, so this pair\n // produces exactly one \u{1F534} and one \u{1F7E2}. Payload: name (+ consecutive_failures).\n 'functions.quarantined': {\n level: 'error',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'broken',\n },\n 'functions.quarantine.cleared': {\n level: 'info',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'ok',\n },\n 'functions.deploy.denied': {\n level: 'warn',\n key: () => 'functions:deploy-denied',\n title: (p) => `function deploy denied (needs the ${str(p.required_tier)} tier)`,\n state: 'broken',\n },\n\n // \u2500\u2500 The lifecycle failure events (live since 2026-09-10). Every one of these\n // carries `level` + `state` in its payload (the platform's failure-event\n // vocabulary gate enforces it), so the rules defer to the event. Each\n // failure/recovery pair shares a key, so a recovery posts RESOLVED. \u2500\u2500\u2500\u2500\u2500\u2500\n 'notifications.delivery.dead_lettered': {\n level: 'payload', // emitted as error/broken; payload: delivery_id, template_id, error_class, attempts\n key: (p) => `notifications:dead-letter:${str(p.template_id, 'all')}`,\n title: (p) => `notification delivery dead-lettered (${str(p.template_id, 'all templates')})`,\n },\n 'webhooks.delivery.dead_lettered': {\n level: 'payload', // error/broken; payload: subscription_id, target_host, attempts, error_class\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.delivery.recovered': {\n level: 'payload', // info/ok \u2014 same key as the dead-letter \u2192 RESOLVED\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.inbound.rejected': {\n level: 'payload', // warn/broken; payload: source_id, provider, reason, fingerprint (never the body)\n key: (p) => `webhooks:inbound:${str(p.source_id)}:${str(p.reason)}`,\n title: (p) => `inbound webhook rejected (${str(p.provider, 'source')} ${str(p.source_id)}: ${str(p.reason)})`,\n },\n 'jobs.schedule.missed': {\n level: 'payload', // warn/STALE \u2014 a late tick is stale, not broken; payload: schedule_id, job_name, late_seconds\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n 'jobs.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED on the next on-time fire\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n // A function's cron trigger that produced NO run for 2\xD7 its interval \u2014 the\n // platform's silent-schedule watch (error/stale), and the recovery on the same\n // key. Payload: function, schedule_id, cron, last_run_at, expected_by.\n 'functions.schedule.missed': {\n level: 'payload',\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))} (last run ${str(p.last_run_at, 'never')})`,\n },\n 'functions.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED when a run appears again\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))}`,\n },\n 'functions.run.failed': {\n level: 'payload', // error/broken; payload: name, trigger, run_id, error_class\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n 'functions.run.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n};\n\n// the schedule tick, or a hand invoke (`POST /v1/fn/alerts`) carrying `{ test: true }`\ntype Envelope = CronFunctionEnvelope | HttpFunctionEnvelope<{ test?: boolean }>;\ninterface AuditRow { id: string | number; event: string; payload?: Payload; created_at?: string }\ninterface StateData {\n key: string; state?: State; level?: Level; event?: string; detail?: string;\n cursor?: string; last_event_id?: string; last_alert_at?: string; updated_at?: string;\n}\ninterface Item { item_id: string; version?: number; data: StateData }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const hook = env.secrets?.slack_webhook_url;\n const readKey = env.scoped_jwts?.['control-plane'];\n if (!cms || !readKey) return Response.json({ error: 'missing cms or audit:read scope' }, { status: 403 });\n if (!hook) return Response.json({ error: 'missing secrets' }, { status: 409 });\n\n // Manual wiring check \u2014 posts, touches nothing else.\n if (env.payload?.test) {\n const r = await post(hook, `\u{1F7E2} [INFO] alerts-to-slack is wired (test message)`);\n return Response.json({ test: true, posted: r.ok, status: r.status });\n }\n\n const store = new Store(base, cms);\n\n // 1. the watermark (first run: start at the newest audit id \u2014 no history storm)\n const cursorRow = await store.byKey('__cursor__');\n if (!cursorRow) {\n const head = await newestAuditId(base, readKey);\n await store.create({ key: '__cursor__', cursor: head, updated_at: iso() });\n return Response.json({ initialized: true, cursor: head });\n }\n let cursor = cursorRow.data.cursor ?? '0';\n const cursorVersion = cursorRow.version;\n\n // 2\u20134. drain + evaluate\n const posts: string[] = [];\n let scanned = 0;\n let matched = 0;\n for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {\n const { rows, next } = await exportAudit(base, readKey, cursor);\n for (const row of rows) {\n scanned++;\n const rule = RULES[row.event];\n if (rule) {\n matched++;\n const msg = await evaluate(store, rule, row);\n if (msg) posts.push(msg);\n }\n cursor = String(row.id);\n }\n if (!next) break;\n cursor = next;\n }\n\n // deliver (bounded)\n let posted = 0;\n const lines = posts.slice(0, MAX_POSTS_PER_RUN);\n if (posts.length > MAX_POSTS_PER_RUN) lines.push(`\u2026 and ${posts.length - MAX_POSTS_PER_RUN} more state changes this tick`);\n for (const text of lines) {\n const r = await post(hook, text);\n if (r.ok) posted++;\n }\n\n // 5. advance the watermark \u2014 If-Match: a concurrent run that already moved it wins\n const advanced = await store.patch(cursorRow.item_id, cursorVersion, { cursor, updated_at: iso() });\n\n return Response.json({ scanned, matched, alerts: posts.length, posted, cursor, advanced });\n },\n};\n\n/** Decide, for one matched audit row, whether a message is due; persist the state. */\nasync function evaluate(store: Store, rule: Rule, row: AuditRow): Promise<string | null> {\n const p = row.payload ?? {};\n const key = rule.key(p);\n const next: State = rule.state ?? (isState(p.state) ? p.state : 'broken');\n const level: Level = rule.level === 'payload' ? (isLevel(p.level) ? p.level : 'error') : rule.level;\n const title = rule.title(p);\n const detail = str(p.reason ?? p.error ?? p.message ?? p.last_error_msg, '').slice(0, DETAIL_MAX);\n const now = iso();\n const eventId = String(row.id);\n const base: StateData = { key, state: next, level, event: row.event, detail, last_event_id: eventId, updated_at: now };\n\n const current = await store.byKey(key);\n if (!current) {\n // First sighting. A green first sighting is remembered silently \u2014 nothing was red.\n if (next === 'ok') { await store.create(base); return null; }\n const created = await store.create({ ...base, last_alert_at: now });\n return created ? format(level, next, title, detail, row.created_at) : null; // 409 = a racing run owns it\n }\n const prev = current.data.state ?? 'ok';\n if (prev !== next) {\n // A CROSSING. If-Match: exactly one concurrent run wins the transition.\n const ok = await store.patch(current.item_id, current.version, { ...base, last_alert_at: now });\n if (!ok) return null;\n return next === 'ok'\n ? `\u{1F7E2} RESOLVED: ${title} is back to ok (was ${prev})`\n : format(level, next, title, detail, row.created_at);\n }\n // Same state. Red stays quiet \u2014 unless a reminder is due.\n if (next !== 'ok' && REPEAT_AFTER_HOURS > 0) {\n const last = Date.parse(current.data.last_alert_at ?? '') || 0;\n if (Date.now() - last >= REPEAT_AFTER_HOURS * 3600e3) {\n const ok = await store.patch(current.item_id, current.version, { ...base, last_alert_at: now });\n return ok ? `\u23F0 STILL ${next.toUpperCase()}: ${title} (since ${current.data.last_alert_at ?? '?'})` : null;\n }\n }\n await store.patch(current.item_id, current.version, base); // keep detail/last_event_id fresh, no post\n return null;\n}\n\nfunction format(level: Level, state: State, title: string, detail: string, at?: string): string {\n const dot = level === 'error' ? '\u{1F534}' : level === 'warn' ? '\u{1F7E0}' : '\u{1F535}';\n return `${dot} [${level.toUpperCase()}] ${title} \u2192 ${state.toUpperCase()}${detail ? ` \u2014 ${detail}` : ''}${at ? ` (at ${at})` : ''}`;\n}\n\n// \u2500\u2500 the audit stream (ascending NDJSON export; `x-vxil-next-after-id` continues it) \u2500\u2500\nasync function exportAudit(base: string, key: string, afterId: string): Promise<{ rows: AuditRow[]; next: string | null }> {\n const res = await fetch(`${base}/v1/audit/export?after_id=${encodeURIComponent(afterId)}&limit=500`, {\n headers: { authorization: `Bearer ${key}` },\n });\n if (!res.ok) throw new Error(`audit export ${res.status}`);\n const rows = (await res.text()).split('\\n').filter(Boolean).map((l) => JSON.parse(l) as AuditRow);\n return { rows, next: res.headers.get('x-vxil-next-after-id') };\n}\nasync function newestAuditId(base: string, key: string): Promise<string> {\n const res = await fetch(`${base}/v1/audit?limit=1`, { headers: { authorization: `Bearer ${key}` } });\n if (!res.ok) throw new Error(`audit list ${res.status}`);\n const body = (await res.json()) as { data?: { events?: AuditRow[] } };\n return String(body.data?.events?.[0]?.id ?? '0');\n}\n\n// \u2500\u2500 the cms state store (the REST envelope: { data: { items:[{item_id, version, data}] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() { return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' }; }\n async byKey(key: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${this.base}/v1/cms/items/alert_state?filter=${filter}&limit=1`, { headers: this.h() });\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 (unique key already claimed by a concurrent run) */\n async create(data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/alert_state`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ data, status: 'published' }),\n });\n return res.ok;\n }\n /** false on 409 version_conflict (another run transitioned this key first) */\n async patch(itemId: string, version: number | undefined, data: Partial<StateData>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/alert_state/${itemId}`, {\n method: 'PATCH',\n headers: { ...this.h(), ...(version !== undefined ? { 'if-match': String(version) } : {}) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n\n// \u2500\u2500 the chat webhook: `text` is Slack's field, `content` is Discord's \u2014 send both,\n// EXCEPT to a Google Chat space webhook (chat.googleapis.com), which takes\n// `{ text }` alone \u2014 an unknown field can be rejected there, so it is dropped.\nasync function post(url: string, text: string): Promise<{ ok: boolean; status: number }> {\n let host = '';\n try { host = new URL(url).hostname; } catch { /* unparseable \u2192 the generic body below */ }\n const body = host === 'chat.googleapis.com' ? { text } : { text, content: text };\n const res = await fetch(url, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n }).catch(() => null);\n return { ok: Boolean(res?.ok), status: res?.status ?? 0 };\n}\n\nconst iso = () => new Date().toISOString();\nconst isState = (v: unknown): v is State => v === 'ok' || v === 'stale' || v === 'broken' || v === 'could_not_check';\nconst isLevel = (v: unknown): v is Level => v === 'info' || v === 'warn' || v === 'error';\n"
19316
19930
  }
19317
19931
  },
19318
19932
  {
@@ -19366,16 +19980,15 @@ export default defineConfig({
19366
19980
  "stripe_key",
19367
19981
  "shop_token",
19368
19982
  "vxil_peer_key",
19369
- "vxil_read_key",
19370
19983
  "slack_webhook_url",
19371
19984
  "oidc_client_secret"
19372
19985
  ],
19373
- "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// \"Ops dashboard\" \u2014 a PRIVATE dashboard for one team, wired to the team's own\n// services and stores: a payments provider, a shop or any REST API, the team's\n// other vxil projects, and inbound webhooks. Live KPI tiles, hourly and daily\n// trends, one updates feed, threshold alerts that open incidents, team roles.\n//\n// DISTRIBUTION OVER SHIPPED PRIMITIVES \u2014 there is no connectors feature: a\n// connector is a function + a secret + egressAllow. There is no dashboard\n// engine either: the tiles are cms rows, the trends are cms rows, the feed is\n// cms rows, and your own frontend renders them (examples/apps/team-dashboard).\n//\n// pull \u2192 functions `collect` polls each integration on a cron and writes\n// `metric_latest` (one row per series) + one `metric_points` row\n// per CLOSED hour; `drain-inbound` turns received webhooks into\n// `events_feed` rows\n// push \u2192 `drain-inbound` also binds `payments.` platform events, so the\n// payments integration's lifecycle lands in the feed in seconds\n// evaluate \u2192 `evaluate-alerts` checks `alert_rules` against `metric_latest`\n// and opens/resolves `incidents` once per crossing (+ chat + mail)\n// show \u2192 realtime change-data on three channels; staff read everything\n// with a read-only browser key; every staff WRITE goes through\n// `ops-action`, which checks the caller's org permission first\n//\n// Plan cadence: as shipped this is a Developer-plan config (4 crons, the\n// fastest every minute). The Free plan allows 5 functions and 3 crons, the\n// fastest every 15 minutes: set the two 5-minute schedules to '*/15 * * * *'\n// and drop drain-inbound's cron trigger (its `payments.` trigger still works),\n// or fold compact-daily into evaluate-alerts. See the README's limits table.\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\n/** Org roles allowed to READ the restricted (revenue-grade) numbers. `owner` is\n * the built-in god role; `finance` and `ops-admin` are custom roles you define\n * once over the API (README step 3). An identity-provider group with the same\n * name works too: the IdP's `groups` claim and the org role travel together. */\nconst MONEY_READERS = ['finance', 'ops-admin', 'owner'];\n\nexport default defineConfig({\n env: 'staging',\n\n features: {\n // \u2500\u2500 Staff-only sign-in \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // OIDC-ONLY + allowedDomains IS THE STAFF GATE. Never turn magic link or\n // email sign-up back on in a project holding company data: the collections\n // below are shared (no owner field) and every signed-in person reads all of\n // them, so an open sign-up would let ANY address read every number here.\n auth: {\n methods: { emailPassword: false, magicLink: false },\n providers: {\n oidc: {\n issuer: 'https://login.example-idp.com', // your identity provider (https, no query)\n clientId: 'ops-dashboard', // not a secret \u2014 it rides every authorize URL\n clientSecretRef: 'secret:oidc_client_secret',\n claims: { email: 'email', name: 'name', roles: 'groups' }, // IdP groups \u2192 session roles\n allowedDomains: ['example.com'], // fail-closed: any other domain is refused\n autoLink: true,\n },\n },\n // The member's org role rides the session, so `readRoles` gates without a\n // round-trip. It is a snapshot: revocation-grade checks call orgs (the\n // functions do, before every write).\n orgClaims: { enabled: true },\n session: { ttlMinutes: 60, refreshTtlDays: 7 },\n security: { allowedRedirectOrigins: ['https://ops.example.com', 'http://localhost:5173'] },\n },\n\n // Roles are rows, not config \u2014 the README defines ops-viewer, operator,\n // finance and ops-admin with `POST /v1/orgs/roles` and creates the one org\n // (slug `ops`) every staff member joins.\n orgs: { enabled: true },\n\n realtime: {},\n\n // One alert channel besides chat: an e-mail + in-app message to every org\n // member holding operator, ops-admin or owner, through the built-in\n // `transactional` template (subject / paragraph / call-to-action). The mock\n // provider delivers nothing; switch to your mail provider before relying on it.\n notifications: { provider: 'mock', fromEmail: 'ops@ops-dashboard.example', inboxEnabled: true },\n\n // Inbound webhook sources (one per external service that pushes to you)\n // are runtime rows: `POST /v1/webhooks/sources` \u2014 see the README.\n webhooks: {},\n\n functions: { enabled: true },\n\n cms: {\n // OFF on purpose: these collections have no owner \u2014 staff read all of\n // them \u2014 and the browser key holds NO cms:write, so an end-user session\n // can read but never write. Every write is a function.\n strictEndUserScope: false,\n // An hourly series is 8,760 rows a year; compact-daily prunes hour rows\n // after 90 days. Raise this if you track more than ~150 series.\n limits: { maxItemsPerCollection: 200_000 },\n\n hooks: {\n // The time bucket is derived from the point's own timestamp \u2014 the\n // writer cannot disagree with it. Hour: '2026-10-04T13'; day: '2026-10-04'.\n point_bucket: {\n collection: 'metric_points',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'bucket',\n expr: \"item.grain == 'day' ? substr(item.ts, 0, 10) : substr(item.ts, 0, 13)\",\n },\n // One row per (series, grain, bucket): the unique point_key makes a\n // re-delivered cron tick a clean 409 instead of a duplicate point.\n // Derives run in declaration order, so this sees the bucket above.\n point_key: {\n collection: 'metric_points',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'point_key',\n expr: \"concat(item.series, '|', item.grain, '|', item.bucket)\",\n },\n // Incidents move forward only: open \u2192 acked \u2192 resolved (or open \u2192 resolved).\n incident_stage: {\n collection: 'incidents',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.state == before.state'\n + \" || (before.state == 'open' && (item.state == 'acked' || item.state == 'resolved'))\"\n + \" || (before.state == 'acked' && item.state == 'resolved')\",\n message: 'incidents move open \u2192 acked \u2192 resolved',\n },\n rule_op: {\n collection: 'alert_rules',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.op == '>' || item.op == '<' || item.op == '>=' || item.op == '<=' || item.op == 'stale'\",\n message: \"op must be one of > < >= <= stale\",\n },\n },\n\n // Realtime change-data. A FULL frame carries every field of the row to\n // every subscriber \u2014 including a `readRoles` field the subscriber may not\n // read \u2014 so a collection with any readRoles field publishes IDS ONLY and\n // the client re-reads the row (the read applies the gate). The CI gate\n // `cdc-gated-fields` refuses a full frame on such a collection.\n cdc: {\n tiles_live: { collection: 'metric_latest', channel: 'ops:metrics', events: ['created', 'updated'], payload: 'ids' },\n incidents_live: { collection: 'incidents', channel: 'ops:incidents', payload: 'ids' },\n feed_live: { collection: 'events_feed', channel: 'ops:events', events: ['created'], payload: 'full' },\n },\n\n // The DECLARATIVE twin of functions/compact-daily.ts, shown and left off:\n //\n // readModels: {\n // daily_by_series: {\n // collection: 'metric_points', kind: 'aggregate',\n // spec: {\n // aggregates: [{ fn: 'avg', field: 'value', as: 'avg' }, { fn: 'max', field: 'value', as: 'max' }, { fn: 'count' }],\n // groupBy: ['series', 'bucket'], filter: { grain: 'hour' }, window: { field: 'ts', sinceDays: 2 },\n // },\n // // materialize: { cron: '15 0 * * *', to: 'metric_daily' },\n // },\n // },\n //\n // A materialized read model rewrites a rollup collection on a schedule.\n // This blueprint keeps the imperative function instead because it also\n // compacts the gated restricted_value, writes day rows into the SAME\n // collection the charts already read, and prunes old hour rows.\n },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4 / n1\u2013n2 / t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n // One row per connected source. NON-SECRET settings only \u2014 a token never\n // lives here (ops-action strips token-looking keys from every write).\n integrations: {\n singular: 'integration',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' }, // e.g. stripe, shop, billing-project\n kind: { type: 'string', required: true, indexSlot: 's2', validation: { enum: ['stripe', 'rest-json', 'vxil-project', 'webhook-source'] } },\n status: { type: 'string', indexSlot: 's3', validation: { enum: ['ok', 'stale', 'broken', 'could_not_check', 'paused'] } },\n owner: { type: 'string', indexSlot: 's4' }, // the person to ask\n consecutive_failures: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n poll_every_min: { type: 'int', indexSlot: 'n2', validation: { min: 1, max: 1440 } },\n last_ok_at: { type: 'datetime', indexSlot: 't1' },\n next_due_at: { type: 'datetime', indexSlot: 't2' }, // collect reads `next_due_at <= now`, sorted\n label: { type: 'string', validation: { max: 120 } },\n enabled: { type: 'bool', indexed: true },\n // base_url, path, series[] (name, json_path, unit, sensitive), source_id,\n // aggregates[], link_url \u2014 never a credential\n config: { type: 'json' },\n last_error: { type: 'text', validation: { max: 500 } },\n last_error_at: { type: 'datetime' },\n cursor: { type: 'string' }, // webhook sources: the newest event already in the feed\n },\n },\n\n // The trend store: one row per series per CLOSED hour (grain 'hour') and\n // one per day (grain 'day', written by compact-daily). Never raw samples.\n metric_points: {\n singular: 'metric_point',\n fields: {\n series: { type: 'string', required: true, indexSlot: 's1' }, // 'source:metric[:dim]'\n grain: { type: 'string', required: true, indexSlot: 's2', validation: { enum: ['hour', 'day'] } },\n bucket: { type: 'string', indexSlot: 's3' }, // derived (point_bucket)\n point_key: { type: 'string', unique: true, indexSlot: 's4' }, // derived (point_key)\n value: { type: 'float', indexSlot: 'n1' },\n restricted_value: { type: 'float', indexSlot: 'n2', readRoles: MONEY_READERS },\n ts: { type: 'datetime', required: true, indexSlot: 't1' }, // the bucket's start\n unit: { type: 'string' },\n samples: { type: 'int' },\n min: { type: 'float' },\n max: { type: 'float' },\n },\n },\n\n // The tiles: one row per series, patched on every collect tick.\n metric_latest: {\n singular: 'metric_latest',\n fields: {\n series: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n source: { type: 'string', indexSlot: 's2' }, // the integration key\n status: { type: 'string', indexSlot: 's3', validation: { enum: ['ok', 'warn', 'crit', 'stale'] } },\n last_bucket: { type: 'string', indexSlot: 's4' }, // the newest hour already in metric_points\n value: { type: 'float', indexSlot: 'n1' },\n restricted_value: { type: 'float', indexSlot: 'n2', readRoles: MONEY_READERS },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n label: { type: 'string' },\n unit: { type: 'string' },\n sensitive: { type: 'bool' }, // true \u21D2 the number lives in restricted_value only\n hour_samples: { type: 'int' },\n hour_min: { type: 'float' }, // non-sensitive series only\n hour_max: { type: 'float' },\n },\n },\n\n // The updates feed. Title + summary only \u2014 never a vendor payload, which\n // can carry personal data, and this collection's change-data is full.\n events_feed: {\n singular: 'event',\n fields: {\n source: { type: 'string', required: true, indexSlot: 's1' },\n kind: { type: 'string', indexSlot: 's2' },\n severity: { type: 'string', indexSlot: 's3', validation: { enum: ['info', 'warn', 'error'] } },\n ext_id: { type: 'string', unique: true, indexSlot: 's4' }, // the sender's event id \u21D2 a redelivery is a 409\n occurred_at: { type: 'datetime', indexSlot: 't1' },\n title: { type: 'string', validation: { max: 200 } },\n url: { type: 'string', validation: { max: 500 } },\n summary: { type: 'text', validation: { max: 2000 } },\n },\n },\n\n alert_rules: {\n singular: 'alert_rule',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n series: { type: 'string', required: true, indexSlot: 's2' },\n state: { type: 'string', indexSlot: 's3', validation: { enum: ['ok', 'firing'] } },\n severity: { type: 'string', indexSlot: 's4', validation: { enum: ['info', 'warn', 'crit'] } },\n threshold: { type: 'float', indexSlot: 'n1' }, // for 'stale': minutes without a fresh value\n for_minutes: { type: 'int', indexSlot: 'n2', validation: { min: 0 } },\n last_eval_at: { type: 'datetime', indexSlot: 't1' },\n last_fired_at: { type: 'datetime', indexSlot: 't2' },\n op: { type: 'string', required: true }, // > < >= <= stale (rule_op hook)\n enabled: { type: 'bool', indexed: true },\n channels: { type: 'json' }, // { chat: bool, email: bool }\n breach_since: { type: 'datetime' },\n repeat_after_hours: { type: 'int', validation: { min: 0 } },\n title: { type: 'string', validation: { max: 200 } },\n },\n },\n\n incidents: {\n singular: 'incident',\n fields: {\n rule: { type: 'string', required: true, indexSlot: 's1' },\n state: { type: 'string', required: true, indexSlot: 's2', validation: { enum: ['open', 'acked', 'resolved'] } },\n severity: { type: 'string', indexSlot: 's3' },\n acked_by: { type: 'string', indexSlot: 's4' },\n opened_at: { type: 'datetime', indexSlot: 't1' },\n resolved_at: { type: 'datetime', indexSlot: 't2' },\n title: { type: 'string', validation: { max: 200 } },\n last_value: { type: 'float' }, // null for a sensitive series\n note: { type: 'text', validation: { max: 2000 } },\n },\n },\n\n // Function memory: the `__compact__` row holds the last day compact-daily finished.\n ops_state: {\n singular: 'ops_state',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n cursor: { type: 'string' },\n },\n },\n },\n },\n\n // \u2500\u2500 The code that isn't config (guide ch. 8) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // PULL. Every 5 minutes: poll each due integration, write the tiles and the\n // closed hour. Also an http door \u2014 the \"Sync now\" button \u2014 that checks the\n // caller holds `integrations.sync` in the ops org first.\n collect: {\n entry: './functions/collect.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free: '*/15 * * * *'\n triggers: [{ kind: 'http' }],\n scopes: ['cms:read', 'cms:write', 'orgs:read'],\n // Every ref here must hold a value or the function refuses to run\n // (409 secret_missing): drop the sources you do not poll.\n secrets: ['secret:stripe_key', 'secret:shop_token', 'secret:vxil_peer_key'],\n // Deny-by-default egress: every REST host you poll goes here, then\n // `vxil push`. www.githubstatus.com is the keyless demo source. Which\n // host each SECRET may go to is fixed in functions/collect.ts\n // (SECRET_HOST / PEER_SECRETS), not by an integration row.\n egressAllow: ['api.stripe.com', 'shop.example.com', 'www.githubstatus.com'],\n limits: { timeoutMs: 20_000 }, // one slow vendor call fails in 20 s, not 30\n },\n\n // PUSH. Every minute: received webhooks \u2192 feed rows. And within seconds:\n // this project's own payments-integration events (the `payments.` trigger).\n 'drain-inbound': {\n entry: './functions/drain-inbound.ts',\n trigger: { kind: 'cron', schedule: '* * * * *' }, // Free: remove this trigger (3 crons max)\n triggers: [{ kind: 'webhook', source: 'payments.' }],\n scopes: ['cms:read', 'cms:write'],\n // Received webhook events are not on a function's scoped callback, so the\n // drain reads them with a key of THIS project holding only webhooks:read.\n secrets: ['secret:vxil_read_key'],\n egressAllow: [],\n },\n\n // EVALUATE. Every 5 minutes: rules \xD7 latest values \u2192 incidents, once per crossing.\n 'evaluate-alerts': {\n entry: './functions/evaluate-alerts.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free: '*/15 * * * *'\n scopes: ['cms:read', 'cms:write', 'notifications:send', 'orgs:read'],\n secrets: ['secret:slack_webhook_url'],\n // The chat host. Add 'discord.com' for a Discord webhook or\n // 'chat.googleapis.com' for a Google Chat space webhook.\n egressAllow: ['hooks.slack.com'],\n },\n\n // Nightly: hour rows \u2192 one day row per series, then prune old hour rows.\n 'compact-daily': {\n entry: './functions/compact-daily.ts',\n trigger: { kind: 'cron', schedule: '15 0 * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // SHOW (the write half). The dashboard's only write door: ack/resolve an\n // incident, edit or toggle a rule, edit or pause an integration \u2014 each\n // gated on an org permission. Audited as the project; the person is\n // recorded in the row (acked_by).\n 'ops-action': {\n entry: './functions/ops-action.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'orgs:read'],\n egressAllow: [],\n },\n },\n\n // References only \u2014 values are set once with `vxil secrets set` and never appear here.\n secrets: {\n stripe_key: { feature: 'functions', description: 'Stripe RESTRICTED key, read-only on balance and charges' },\n shop_token: { feature: 'functions', description: 'bearer token of the REST API collect polls (rest-json integrations)' },\n vxil_peer_key: { feature: 'functions', description: 'API key of ANOTHER vxil project: usage:read (+ cms:read for aggregates)' },\n vxil_read_key: { feature: 'functions', description: 'API key of THIS project holding only webhooks:read' },\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord / Google Chat space webhook) URL' },\n oidc_client_secret: { feature: 'auth', description: 'OIDC client secret of the staff identity provider' },\n },\n\n seed: {\n cms: [\n {\n collection: 'integrations',\n items: [\n {\n key: 'demo', kind: 'rest-json', label: 'Public status page (demo)', status: 'ok', enabled: true,\n poll_every_min: 5, consecutive_failures: 0, next_due_at: '2026-01-01T00:00:00Z',\n config: {\n base_url: 'https://www.githubstatus.com', path: '/api/v2/incidents/unresolved.json', auth: 'none',\n series: [{ name: 'open_incidents', json_path: 'incidents.length', unit: 'count' }],\n },\n },\n {\n key: 'stripe', kind: 'stripe', label: 'Payments provider', status: 'paused', enabled: false,\n poll_every_min: 5, consecutive_failures: 0, config: {},\n },\n {\n key: 'shop', kind: 'rest-json', label: 'Shop API', status: 'paused', enabled: false,\n poll_every_min: 15, consecutive_failures: 0,\n config: {\n base_url: 'https://shop.example.com', path: '/api/stats',\n series: [\n { name: 'orders_1h', json_path: 'orders.last_hour', unit: 'count' },\n { name: 'revenue_1h', json_path: 'revenue.last_hour', unit: 'usd', sensitive: true },\n ],\n },\n },\n {\n key: 'billing-project', kind: 'vxil-project', label: 'Billing backend (another vxil project)', status: 'paused',\n enabled: false, poll_every_min: 60, consecutive_failures: 0,\n config: { aggregates: [{ name: 'signups_24h', collection: 'accounts', fn: 'count', window_hours: 24 }] },\n },\n {\n key: 'support-inbox', kind: 'webhook-source', label: 'Support tool webhooks', status: 'paused', enabled: false,\n poll_every_min: 1, consecutive_failures: 0, config: { source_id: 'whs_replace_me' },\n },\n ],\n },\n {\n collection: 'alert_rules',\n items: [\n {\n key: 'payment-error-rate', series: 'stripe:charge_error_rate_1h', op: '>', threshold: 5, for_minutes: 10,\n severity: 'crit', state: 'ok', enabled: true, repeat_after_hours: 4, channels: { chat: true, email: true },\n title: 'Payment error rate above 5%',\n },\n {\n key: 'orders-dry', series: 'shop:orders_1h', op: '<', threshold: 1, for_minutes: 60,\n severity: 'warn', state: 'ok', enabled: true, repeat_after_hours: 24, channels: { chat: true, email: false },\n title: 'No orders in the last hour',\n },\n {\n key: 'demo-stale', series: 'demo:open_incidents', op: 'stale', threshold: 30, for_minutes: 0,\n severity: 'warn', state: 'ok', enabled: true, repeat_after_hours: 24, channels: { chat: true, email: false },\n title: 'Status data older than 30 minutes',\n },\n ],\n },\n ],\n },\n});\n",
19374
- "readme": '# Ops dashboard (ops)\n\nA **private dashboard for one team**, wired to the team\'s own services and stores: your payments provider, a\nshop or any REST API, your other vxil projects, and the webhooks those services send you. It shows **live KPI\ntiles**, **hourly and daily trends**, **one updates feed**, **threshold alerts that open incidents** (once per\ncrossing, with a chat and an e-mail message), and **team roles** \u2014 revenue-grade numbers are readable only by the\nroles you name. Staff sign in through your identity provider and nothing else.\n\nIt is distribution over shipped building blocks. There is **no connectors feature**: a connector is a function +\na secret + a host in `egressAllow`. There is no dashboard engine either: every tile, point, feed entry and\nincident is a cms row, and your own frontend renders them \u2014 the reference frontend is the\n`team-dashboard` showcase app (https://vxil.com/showcase, downloadable as a zip).\n\nWhat it teaches that no other blueprint does: **time series on the cms** (closed-hour writes, a derived bucket,\nnightly compaction), **gated numbers** (`readRoles` + change-data that never carries them), and a **read-only\nbrowser** whose every write goes through one permission-checked function.\n\n## 1. The shape\n\n```\n your services \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 vxil project (this blueprint) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 your frontend\n (showcase app: team-dashboard)\n payments provider \u2500\u2510 PULL \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 metric_latest \u2500\u2500 tiles \u2500\u2500\u2510\n REST / shop API \u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25B6\u2502 collect \u2502 metric_points \u2500\u2500 trends \u2500\u2524 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n other vxil project \u2518 (cron) \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u251C\u2500\u2500\u25B6\u2502 read-only key \u2502\n \u25B2 compact-daily (nightly) \u2502 \u2502 + staff session \u2502\n inbound webhooks \u2500\u2500\u2510 PUSH \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502 \u2502 + realtime \u2502\n payments events \u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25B6\u2502 drain-inbound\u2502 events_feed \u2500\u2500 feed \u2500\u2500\u2524 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502 \u2502 every write\n EVALUATE \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 incidents \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u25BC\n alert_rules \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25B6\u2502 evaluate-alerts\u2502\u2500\u2500\u25B6 chat + e-mail \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502 ops-action \u2502 (org permission\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 checked first)\n```\n\nThe four arrows:\n\n| Arrow | Who | How |\n|---|---|---|\n| **Pull** | `collect`, every 5 minutes | polls each due integration, patches `metric_latest`, writes one `metric_points` row per closed hour |\n| **Push** | `drain-inbound`, every minute + on `payments.` events | received webhooks and the payments integration\'s events \u2192 `events_feed` |\n| **Evaluate** | `evaluate-alerts`, every 5 minutes | `alert_rules` \xD7 `metric_latest` \u2192 `incidents`, chat, e-mail \u2014 once per crossing |\n| **Show** | realtime change-data + your frontend | staff read every collection; the only write door is `ops-action` |\n\n## 2. Apply it\n\n```bash\nmkdir ops-dashboard && cd ops-dashboard && vxil init --template ops-dashboard # init scaffolds into the current directory\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\n# Free plan only: make \xA710\'s cron edits in vxil.config.ts now (4 crons, one every minute, do not fit Free)\n# the secrets (values never touch the config) \u2014 every ref a function declares must hold a value (below):\nprintf \'%s\' "$OIDC_SECRET" | vxil secrets set auth/oidc_client_secret\nprintf \'%s\' "$STRIPE_RK" | vxil secrets set functions/stripe_key # a RESTRICTED, read-only key\nprintf \'%s\' "$SHOP_TOKEN" | vxil secrets set functions/shop_token\nprintf \'%s\' "$PEER_KEY" | vxil secrets set functions/vxil_peer_key # a key of ANOTHER project\nprintf \'%s\' "$READ_KEY" | vxil secrets set functions/vxil_read_key # a key of THIS project: webhooks:read\nprintf \'%s\' "$SLACK_URL" | vxil secrets set functions/slack_webhook_url\nvxil push # collections, hooks, change-data, the five functions\nvxil seed # the demo integration + three example rules\nvxil functions invoke collect # one tick by hand: the demo source fills its first tile\n```\n\n**Every secret a function declares must hold a value**, or that function refuses to run\n(`409 secret_missing`, naming the ref). Before the push, delete from `collect`\'s `secrets` the sources you do\nnot poll (and their `egressAllow` hosts) \u2014 or store a placeholder value for a source you will add later.\n\nAlso replace the placeholders in `vxil.config.ts`: the OIDC `issuer`, `clientId` and\n`allowedDomains`, the `allowedRedirectOrigins` of your frontend, and `DASHBOARD_URL` in\n`functions/evaluate-alerts.ts`.\n\n| Secret | What it is | Least privilege |\n|---|---|---|\n| `oidc_client_secret` | your identity provider\'s client secret for this app | \u2014 |\n| `stripe_key` | a payments-provider **restricted** key | read on Balance and Charges, nothing else |\n| `shop_token` | the bearer token of the REST API a `rest-json` integration polls | a read-only token |\n| `vxil_peer_key` | an API key of **another** vxil project you own | `usage:read`, plus `cms:read` if you aggregate its collections |\n| `vxil_read_key` | an API key of **this** project | `webhooks:read` only. The shipped `drain-inbound` reads received events with it; that is a holdover, not a platform limit \u2014 a function that declares `webhooks:read` reads received events (including the `after` drain) through its own scoped callback, with no stored key |\n| `slack_webhook_url` | a chat incoming-webhook URL | \u2014 |\n\n`vxil_peer_key` and `vxil_read_key` are minted with `vxil keys mint --name <name> --scopes <a,b>` in the project\nthey belong to. `vxil keys mint` acts on your account, so it needs a dashboard session: run `vxil login` once\nfirst (a project key from `vxil quickstart` / `vxil link` is not enough). `vxil link --key` stores the key you give it in `~/.vxil/credentials.json` (so does `vxil login` with its session): treat that file as a secret \u2014 never commit it, copy it into an image or share it.\n\n## 3. Staff-only sign-in, and the roles\n\n`auth` here is **OIDC-only**: `methods.emailPassword` and `methods.magicLink` are off, and\n`providers.oidc.allowedDomains` lists your company domain. A sign-in whose verified e-mail is not on that list\nis refused (`403 oidc_domain_not_allowed`), and with no other method there is no other way in. **That is the\nstaff gate.** Do not turn magic link or e-mail sign-up back on in a project holding company data: these\ncollections have no owner field, every signed-in person reads all of them, so an open sign-up would let any\naddress on the internet read every number on the dashboard.\n\nRoles live in one org. Create it (slug `ops` \u2014 the functions look it up by slug) and the four custom roles once,\nwith a server key that holds `orgs:write`:\n\nEach person signs in once through your identity provider first \u2014 that sign-in creates their user id\n(start it with `vx.auth.oauth.startUrl(\'oidc\', { redirect_uri })`; vxil.com/docs/guide/06-feature-catalog,\n"Starting social or SSO sign-in from a browser app"). Then look ids up by e-mail. Trying it with no\nidentity provider yet? `vxil api POST /v1/users --data \'{"id":"usr_me","email":"you@yourco.com"}\'` creates a\nrow you can use as `$ME` (it cannot sign in, but the org and role checks work).\n\n```bash\nME=$(vxil api GET "/v1/users?email=you@yourco.com" | jq -r \'.users[0].id\')\nOPS=$(vxil api GET "/v1/users?email=operator@yourco.com" | jq -r \'.users[0].id\')\nORG=$(vxil api POST /v1/orgs --data \'{"slug":"ops","name":"Ops","owner_user_id":"\'$ME\'"}\' | jq -r .org_id)\nvxil api POST /v1/orgs/roles --data \'{"role_key":"ops-viewer","name":"Viewer","permissions":["dashboard.read"]}\'\nvxil api POST /v1/orgs/roles --data \'{"role_key":"operator","name":"Operator","permissions":["dashboard.read","incidents.manage","integrations.sync"]}\'\nvxil api POST /v1/orgs/roles --data \'{"role_key":"finance","name":"Finance","permissions":["dashboard.read","revenue.read"]}\'\nvxil api POST /v1/orgs/roles --data \'{"role_key":"ops-admin","name":"Ops admin","permissions":["dashboard.read","revenue.read","incidents.manage","integrations.sync","rules.manage","integrations.manage"]}\'\nvxil api POST /v1/orgs/$ORG/members --data \'{"user_id":"\'$OPS\'","role":"operator"}\'\n```\n\n`owner`, `admin`, `member` and `viewer` are built-in role names with fixed permission sets, which is why the custom ones are\n`ops-viewer` and `ops-admin`. The built-in `owner` holds every permission. The permission strings are yours \u2014\nvxil only answers whether a member holds one (`GET /v1/orgs/{org}/check`).\n\nWith `orgClaims` on, the member\'s role rides the session, and the IdP\'s `groups` claim (named in\n`claims.roles`) rides beside it: a group called `finance` in your identity provider satisfies the same gate as\nthe org role.\n\n## 4. The browser keys\n\nMint two keys in the dashboard under **Keys \u2192 New key** (or with `vxil keys mint` after `vxil login`):\n\n| Key | Class | Scopes | Why |\n|---|---|---|---|\n| `web-auth` | Server | `auth:signin` | signing in is how a session is obtained, so this key cannot require one |\n| `web-data` | Public / thin-client | `cms:read`, `realtime:write`, `functions:invoke` | refused by the edge without a valid staff session |\n\n`web-data` holds **no `cms:write`**. A staff member can read every collection, mint a realtime connect token\nfor a channel (that is what `realtime:write` is for on a thin-client key \u2014 publishing over REST is refused to\nan end-user session) and call functions. Every write \u2014 acknowledging an incident, editing a rule, pausing an\nintegration \u2014 is `POST /v1/fn/ops-action`, which asks orgs whether this person holds the permission before it\nwrites. The write is audited as the project (the function writes with the project\'s cms scope, so the audit\nrow does not name the person); the human is recorded in the row itself \u2014 `acked_by` on an incident:\n\n```ts\nawait vx.asEndUser(session).fn[\'ops-action\']({ op: \'ack\', incident_id: \'itm_\u2026\', note: \'looking\' });\n// 403 { error: \'role_required\', permission: \'incidents.manage\' } for a viewer\n```\n\n| `op` | Permission | Body |\n|---|---|---|\n| `ack`, `resolve` | `incidents.manage` | `{ incident_id, note? }` \u2014 a compare-and-set on the state the button showed |\n| `upsert_rule` | `rules.manage` | `{ rule: { key, series, op, threshold, for_minutes?, severity?, repeat_after_hours?, channels?, title? } }` |\n| `toggle_rule` | `rules.manage` | `{ key, enabled }` |\n| `update_integration` | `integrations.manage` | `{ integration: { key, label?, owner?, poll_every_min?, config? } }` |\n| `pause_integration` | `integrations.manage` | `{ key, paused }` |\n\n"Sync now" is `POST /v1/fn/collect { integration: \'<key>\' }`, gated on `integrations.sync`.\n\n`update_integration` strips every config key that looks like a credential (`key`, `token`, `secret`,\n`password`, `authorization`, \u2026) and every value that looks like a bearer token, and names what it dropped. A\ncredential is a project secret, set with `vxil secrets set`, never from a browser.\n\nThe three settings that decide **where a credential is sent** \u2014 `config.base_url`, `config.auth` and\n`config.peer` \u2014 are not editable through `ops-action` at all: a change answers `409 locked_config` and an edit\nthat leaves them out keeps the stored values. They change only with a server key (`vxil api PATCH\n/v1/cms/items/integrations/<id>`, or a re-seed). `collect` enforces the same thing on its side, in code: each\nsecret is bound to one host (`SECRET_HOST` in `functions/collect.ts` \u2014 `shop_token` \u2192 your API\'s host;\n`stripe_key` \u2192 the payments provider\'s API; peer keys \u2192 the vxil API only, and only the names in\n`PEER_SECRETS`). A row that would send a credential anywhere else turns `broken` without a request being made.\n\n## 5. Adding a source\n\nA source is three edits: a **case in the `adapter` switch** in `functions/collect.ts`, the **host in\n`egressAllow`**, and a **secret**. Then `vxil push`. The egress guard is deny-by-default: a host you forgot\nanswers `403` and the integration turns `broken` with `egress blocked: add <host> to egressAllow and push`.\n\nBefore writing code, try the generic `rest-json` kind \u2014 a row, no code:\n\n```json\n{ "key": "shop", "kind": "rest-json", "enabled": true, "poll_every_min": 5,\n "next_due_at": "2026-01-01T00:00:00Z",\n "config": { "base_url": "https://shop.example.com", "path": "/api/stats",\n "series": [ { "name": "orders_1h", "json_path": "orders.last_hour", "unit": "count" },\n { "name": "revenue_1h", "json_path": "revenue.last_hour", "unit": "usd", "sensitive": true } ] } }\n```\n\nIt sends `Authorization: Bearer <shop_token>` (`"auth": "none"` for a public endpoint) and reads each\n`json_path` (`a.b.0.c`; a trailing `.length` counts an array). Series are named `<integration key>:<name>`.\nThe token goes **only** to the host in `SECRET_HOST.shop_token` at the top of `functions/collect.ts` \u2014 set it to\nyour API\'s host before the push; a `rest-json` row with a token pointing at any other host is refused. A second\nauthenticated REST API is a second secret and its own `case` (below), never a second row reusing `shop_token`.\n\n**A REST API with its own token** \u2014 one secret per vendor, one case:\n\n```ts\ncase \'helpdesk\': {\n const r = await getJson(\'https://api.helpdesk.example/v2/tickets/count?status=open\',\n { authorization: `Bearer ${secrets.helpdesk_token}` }) as { count: number };\n return [{ series: \'helpdesk:open_tickets\', value: r.count, unit: \'count\' }];\n}\n```\n\n**An analytics API that wants a service-account JWT** \u2014 sign it with WebCrypto inside the function (no SDK, no\nNode), then trade it for an access token:\n\n```ts\nasync function serviceToken(sa: { client_email: string; private_key: string }, scope: string): Promise<string> {\n const b64 = (s: string | ArrayBuffer) => btoa(typeof s === \'string\' ? s : String.fromCharCode(...new Uint8Array(s)))\n .replace(/=+$/, \'\').replace(/\\+/g, \'-\').replace(/\\//g, \'_\');\n const now = Math.floor(Date.now() / 1000);\n const unsigned = `${b64(JSON.stringify({ alg: \'RS256\', typ: \'JWT\' }))}.${b64(JSON.stringify({\n iss: sa.client_email, scope, aud: \'https://oauth2.example.com/token\', iat: now, exp: now + 3600 }))}`;\n const der = Uint8Array.from(atob(sa.private_key.replace(/-----[^-]+-----|\\s/g, \'\')), (c) => c.charCodeAt(0));\n const key = await crypto.subtle.importKey(\'pkcs8\', der, { name: \'RSASSA-PKCS1-v1_5\', hash: \'SHA-256\' }, false, [\'sign\']);\n const sig = await crypto.subtle.sign(\'RSASSA-PKCS1-v1_5\', key, new TextEncoder().encode(unsigned));\n const res = await fetch(\'https://oauth2.example.com/token\', { method: \'POST\',\n headers: { \'content-type\': \'application/x-www-form-urlencoded\' },\n body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${unsigned}.${b64(sig)}` });\n return ((await res.json()) as { access_token: string }).access_token;\n}\n```\n\nStore the service-account JSON as one secret, and add both the token host and the API host to `egressAllow`.\n\n**A SQL query over an HTTPS SQL endpoint** \u2014 the function is the SQL client; vxil never connects to your\ndatabase. The pattern, the credential handling and the least-privilege database user are in\nguide chapter 8 (https://vxil.com/docs/guide/08-running-your-code-functions#your-own-database-from-a-function):\n\n```ts\ncase \'warehouse\': {\n const rows = await sql(secrets.database_url, \'select count(*)::int as n from orders where created_at > now() - interval \\\'1 hour\\\'\');\n return [{ series: \'warehouse:orders_1h\', value: rows[0].n, unit: \'count\' }];\n}\n```\n\n**A tool that pushes instead of being polled** \u2014 create an inbound webhook source and point the tool at its\nreceiver URL; then add an integration row of kind `webhook-source` with `config.source_id`:\n\n```ts\nconst { source_id, receiver_url_path } = await vx.webhooks.sources.create({ provider: \'generic\', name: \'support tool\' });\n// the receiver URL is returned once \u2014 paste it into the tool\'s webhook settings\n```\n\n## 6. Time series on the cms\n\n- **Write closed hours, not samples.** Every collect tick patches the series\' `metric_latest` row (value,\n `updated_at`, the hour\'s sample count and min/max). When a tick finds the stored sample belongs to an hour that\n has ended, it first writes that hour\'s closing sample as one `metric_points` row (`grain: \'hour\'`), then\n records the hour in `last_bucket`.\n- **The bucket is derived.** The `point_bucket` hook derives `bucket` from `ts` (`2026-10-04T13` for an hour,\n `2026-10-04` for a day) and `point_key` concatenates series, grain and bucket into a `unique` field \u2014 so a\n redelivered tick or two overlapping ticks produce a `409`, never a second point. Both are Lane-A `derive`\n hooks, not `validate`: the server computes the value on every write, so a writer that sends a wrong bucket (or\n none) is overwritten rather than refused, and no writer \u2014 `collect`, `compact-daily`, a backfill script \u2014 can\n file a point under the wrong hour. Keep them `derive` in any copy of this config.\n- **Compaction.** `compact-daily` aggregates yesterday\'s hour rows per series on the server (avg, min, max,\n count) into one `grain: \'day\'` row, then deletes hour rows older than 90 days and feed rows older than 30\n (the bounded filtered delete, 100 rows a call). Charts read `grain: \'hour\'` for the last days and\n `grain: \'day\'` beyond.\n- **The 50,000-row scan cap.** A server-side aggregate refuses (`422`) rather than scanning more than 50,000 rows.\n One day of hour rows is `series \xD7 24`, so compaction stays far below it even at 500 series; an aggregate over\n a year of raw 5-minute samples (105,120 per series) would not fit even for one series.\n- **Why not raw points.** Every cms write is an audited row and a change-data frame, and every call a function\n makes back into vxil counts as a request. A sample every 5 minutes is 288 rows a day per series; a closed hour\n is 24 \u2014 twelve times fewer writes, frames and requests, and twelve times fewer rows under every scan.\n\nThe config also shows (commented) the declarative twin of `compact-daily`, a `readModels` aggregate. It is left\noff because the function also compacts the gated value, writes into the collection the charts already read, and\nprunes.\n\n## 7. Sensitive numbers\n\nA series marked `sensitive` (revenue, balances) writes its number to `restricted_value`, which declares\n`readRoles: [\'finance\', \'ops-admin\', \'owner\']`; `value` stays null. A signed-in staff member without one of those\nroles gets the row with `restricted_value` **absent** and cannot filter, sort or group by it. Your server key\nand the functions (which run as the server) still see it, so alerts evaluate it \u2014 but a sensitive series\' number\nnever goes into a chat message, an e-mail or an incident row.\n\n**Change-data frames do not apply `readRoles`.** A `payload: \'full\'` frame carries the whole row to every\nsubscriber of the channel. So `metric_latest` and `incidents` publish **ids only** and the client re-reads the\nrow through the cms, where the gate applies; only `events_feed`, which has no gated field, publishes full rows.\nThe CI gate `cdc-gated-fields` refuses a full frame on any collection that declares a `readRoles` field.\n\n## 8. Alerts once per crossing\n\nA rule is `{ series, op, threshold, for_minutes, severity, repeat_after_hours, channels }`; `op` is `>`, `<`,\n`>=`, `<=` or `stale` (no fresh value for `threshold` minutes). `evaluate-alerts`:\n\n1. on a new breach stores `breach_since` and waits `for_minutes`;\n2. when the breach is sustained, creates the incident under `lock: \'incident:<rule>\'` with\n `guard: { filter: { rule, state: { $in: [\'open\', \'acked\'] } }, max: 1 }` \u2014 of two overlapping ticks exactly\n one gets a `201`, and only that one posts the chat message and sends the e-mail (the built-in `transactional`\n template, to every `ops` member holding `operator`, `ops-admin` or `owner`);\n3. stays quiet while it stays red, except one reminder every `repeat_after_hours`;\n4. if the incident create fails for any other reason (a `5xx`, a hook\'s `422`), leaves the rule `ok` with its\n `breach_since` and counts it in `failed` \u2014 the next tick crosses again, so the alert is delayed, never lost;\n5. on recovery flips the rule back (an `If-Match` on its version picks the one tick that does it), resolves the\n open incident and posts one RESOLVED message.\n\nThe chat poster sends Slack\'s `text` and Discord\'s `content`; for Discord add `discord.com` to `egressAllow`; a\nGoogle Chat space webhook (`chat.googleapis.com`, also added to `egressAllow`) gets `{ text }` alone.\n\n## 9. Reading your other vxil projects\n\nAn integration of kind `vxil-project` reads another project with **that project\'s own key** \u2014 one key per\nproject, minted there with the least it needs (`usage:read` for its request meter, `cms:read` to aggregate its\ncollections). It is pull, not push: the other project does not know this dashboard exists, and revoking the key\nthere disconnects it. A second project gets a second secret: declare `secret:<name>` on `collect`, add the name\nto `PEER_SECRETS` in `functions/collect.ts`, and point the row at it with `config.peer` (any name not on that\nlist is refused, so a row cannot pick some other secret and send it as a bearer key).\n\n```json\n{ "key": "billing-project", "kind": "vxil-project", "enabled": true, "poll_every_min": 60,\n "config": { "aggregates": [ { "name": "signups_24h", "collection": "accounts", "fn": "count", "window_hours": 24 } ] } }\n```\n\n## 10. Limits and plans\n\n| | Free (staging / development projects) | Developer |\n|---|---|---|\n| Functions / crons | 5 functions, 3 crons, fastest every 15 min | 50 functions, 20 crons, every minute |\n| As shipped | set the two `*/5` schedules (`collect`, `evaluate-alerts`) to `*/15`; drop `drain-inbound`\'s cron trigger (its `payments.` trigger stays) | runs as shipped |\n| Requests a month | 100,000 | 1,000,000 |\n\nEvery call a function makes back into vxil counts as a request (vendor calls do not). The arithmetic for 3\npolled integrations \xD7 5 series, a 30-day month:\n\n| Function | Per run | Runs a month | Requests |\n|---|---|---|---|\n| `collect` every 5 min | 1 list + per integration (1 list + 5 tile patches + 1 status patch) = 22 | 8,640 | ~190,000 |\n| hour closes | 15 series \xD7 1 point | 720 hours | ~11,000 |\n| `evaluate-alerts` every 5 min | 1 rules read + 1 latest read (+ writes on crossings only) | 8,640 | ~17,000 |\n| `drain-inbound` every minute, 1 source | 1 list + 1 events read (+ 1 write per event) | 43,200 | ~86,000 + events |\n| `compact-daily` | 2 aggregates + 15 day rows + retention deletes | 30 | ~1,500 |\n\nAbout **305,000 requests a month** on Developer. On Free, at 15-minute cadence with 2 integrations \xD7 4 series and\nno inbound drain: `collect` ~37,000, `evaluate-alerts` ~6,000, hour closes ~6,000 \u2014 about **50,000**. A tile list\nread by your frontend counts too, so let realtime change-data tell the page when to re-read instead of polling.\n\n## 11. Compose with\n\n- **alerts-to-slack** \u2014 alerts on the platform\'s own failure events (a dead-lettered job, a quarantined\n function, a failing payments webhook) next to this dashboard\'s business thresholds.\n- **service-monitor** \u2014 uptime checks for your endpoints, written into the same kind of rows.\n- **team-workspace** \u2014 the full org model: invitations, per-resource grants, a field only finance receives.\n\n## 12. Where the frontend lives\n\nThe **App builder** builds public-facing apps. An internal dashboard is your own frontend \u2014 any framework,\nany host \u2014 reading this project with the two keys above. The reference is the `team-dashboard` showcase app (https://vxil.com/showcase):\nsign-in through your identity provider, tiles that re-read on change-data frames, trend charts over\n`metric_points`, the feed, and the incident buttons calling `ops-action`.\n',
19986
+ "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// \"Ops dashboard\" \u2014 a PRIVATE dashboard for one team, wired to the team's own\n// services and stores: a payments provider, a shop or any REST API, the team's\n// other vxil projects, and inbound webhooks. Live KPI tiles, hourly and daily\n// trends, one updates feed, threshold alerts that open incidents, team roles.\n//\n// DISTRIBUTION OVER SHIPPED PRIMITIVES \u2014 there is no connectors feature: a\n// connector is a function + a secret + egressAllow. There is no dashboard\n// engine either: the tiles are cms rows, the trends are cms rows, the feed is\n// cms rows, and your own frontend renders them (examples/apps/team-dashboard).\n//\n// pull \u2192 functions `collect` polls each integration on a cron and writes\n// `metric_latest` (one row per series) + one `metric_points` row\n// per CLOSED hour; `drain-inbound` turns received webhooks into\n// `events_feed` rows\n// push \u2192 `drain-inbound` also binds `payments.` platform events, so the\n// payments integration's lifecycle lands in the feed in seconds\n// evaluate \u2192 `evaluate-alerts` checks `alert_rules` against `metric_latest`\n// and opens/resolves `incidents` once per crossing (+ chat + mail)\n// show \u2192 realtime change-data on three channels; staff read everything\n// with a read-only browser key; every staff WRITE goes through\n// `ops-action`, which checks the caller's org permission first\n//\n// Plan cadence: as shipped this is a Developer-plan config (4 crons, the\n// fastest every minute). The Free plan allows 5 functions and 3 crons, the\n// fastest every 15 minutes: set the two 5-minute schedules to '*/15 * * * *'\n// and drop drain-inbound's cron trigger (its `payments.` trigger still works),\n// or fold compact-daily into evaluate-alerts. See the README's limits table.\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\n/** Org roles allowed to READ the restricted (revenue-grade) numbers. `owner` is\n * the built-in god role; `finance` and `ops-admin` are custom roles you define\n * once over the API (README step 3). An identity-provider group with the same\n * name works too: the IdP's `groups` claim and the org role travel together. */\nconst MONEY_READERS = ['finance', 'ops-admin', 'owner'];\n\nexport default defineConfig({\n env: 'staging',\n\n features: {\n // \u2500\u2500 Staff-only sign-in \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // OIDC-ONLY + allowedDomains IS THE STAFF GATE. Never turn magic link or\n // email sign-up back on in a project holding company data: the collections\n // below are shared (no owner field) and every signed-in person reads all of\n // them, so an open sign-up would let ANY address read every number here.\n auth: {\n methods: { emailPassword: false, magicLink: false },\n providers: {\n oidc: {\n issuer: 'https://login.example-idp.com', // your identity provider (https, no query)\n clientId: 'ops-dashboard', // not a secret \u2014 it rides every authorize URL\n clientSecretRef: 'secret:oidc_client_secret',\n claims: { email: 'email', name: 'name', roles: 'groups' }, // IdP groups \u2192 session roles\n allowedDomains: ['example.com'], // fail-closed: any other domain is refused\n autoLink: true,\n },\n },\n // The member's org role rides the session, so `readRoles` gates without a\n // round-trip. It is a snapshot: revocation-grade checks call orgs (the\n // functions do, before every write).\n orgClaims: { enabled: true },\n session: { ttlMinutes: 60, refreshTtlDays: 7 },\n security: { allowedRedirectOrigins: ['https://ops.example.com', 'http://localhost:5173'] },\n },\n\n // Roles are rows, not config \u2014 the README defines ops-viewer, operator,\n // finance and ops-admin with `POST /v1/orgs/roles` and creates the one org\n // (slug `ops`) every staff member joins.\n orgs: { enabled: true },\n\n realtime: {},\n\n // One alert channel besides chat: an e-mail + in-app message to every org\n // member holding operator, ops-admin or owner, through the built-in\n // `transactional` template (subject / paragraph / call-to-action). The mock\n // provider delivers nothing; switch to your mail provider before relying on it.\n notifications: { provider: 'mock', fromEmail: 'ops@ops-dashboard.example', inboxEnabled: true },\n\n // Inbound webhook sources (one per external service that pushes to you)\n // are runtime rows: `POST /v1/webhooks/sources` \u2014 see the README.\n webhooks: {},\n\n functions: { enabled: true },\n\n cms: {\n // OFF on purpose: these collections have no owner \u2014 staff read all of\n // them \u2014 and the browser key holds NO cms:write, so an end-user session\n // can read but never write. Every write is a function.\n strictEndUserScope: false,\n // An hourly series is 8,760 rows a year; compact-daily prunes hour rows\n // after 90 days. Raise this if you track more than ~150 series.\n limits: { maxItemsPerCollection: 200_000 },\n\n hooks: {\n // The time bucket is derived from the point's own timestamp \u2014 the\n // writer cannot disagree with it. Hour: '2026-10-04T13'; day: '2026-10-04'.\n point_bucket: {\n collection: 'metric_points',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'bucket',\n expr: \"item.grain == 'day' ? substr(item.ts, 0, 10) : substr(item.ts, 0, 13)\",\n },\n // One row per (series, grain, bucket): the unique point_key makes a\n // re-delivered cron tick a clean 409 instead of a duplicate point.\n // Derives run in declaration order, so this sees the bucket above.\n point_key: {\n collection: 'metric_points',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'point_key',\n expr: \"concat(item.series, '|', item.grain, '|', item.bucket)\",\n },\n // Incidents move forward only: open \u2192 acked \u2192 resolved (or open \u2192 resolved).\n incident_stage: {\n collection: 'incidents',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.state == before.state'\n + \" || (before.state == 'open' && (item.state == 'acked' || item.state == 'resolved'))\"\n + \" || (before.state == 'acked' && item.state == 'resolved')\",\n message: 'incidents move open \u2192 acked \u2192 resolved',\n },\n rule_op: {\n collection: 'alert_rules',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.op == '>' || item.op == '<' || item.op == '>=' || item.op == '<=' || item.op == 'stale'\",\n message: \"op must be one of > < >= <= stale\",\n },\n },\n\n // Realtime change-data. A FULL frame carries every field of the row to\n // every subscriber \u2014 including a `readRoles` field the subscriber may not\n // read \u2014 so a collection with any readRoles field publishes IDS ONLY and\n // the client re-reads the row (the read applies the gate). The CI gate\n // `cdc-gated-fields` refuses a full frame on such a collection.\n cdc: {\n tiles_live: { collection: 'metric_latest', channel: 'ops:metrics', events: ['created', 'updated'], payload: 'ids' },\n incidents_live: { collection: 'incidents', channel: 'ops:incidents', payload: 'ids' },\n feed_live: { collection: 'events_feed', channel: 'ops:events', events: ['created'], payload: 'full' },\n },\n\n // The DECLARATIVE twin of functions/compact-daily.ts, shown and left off:\n //\n // readModels: {\n // daily_by_series: {\n // collection: 'metric_points', kind: 'aggregate',\n // spec: {\n // aggregates: [{ fn: 'avg', field: 'value', as: 'avg' }, { fn: 'max', field: 'value', as: 'max' }, { fn: 'count' }],\n // groupBy: ['series', 'bucket'], filter: { grain: 'hour' }, window: { field: 'ts', sinceDays: 2 },\n // },\n // // materialize: { cron: '15 0 * * *', to: 'metric_daily' },\n // },\n // },\n //\n // A materialized read model rewrites a rollup collection on a schedule.\n // This blueprint keeps the imperative function instead because it also\n // compacts the gated restricted_value, writes day rows into the SAME\n // collection the charts already read, and prunes old hour rows.\n },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4 / n1\u2013n2 / t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n // One row per connected source. NON-SECRET settings only \u2014 a token never\n // lives here (ops-action strips token-looking keys from every write).\n integrations: {\n singular: 'integration',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' }, // e.g. stripe, shop, billing-project\n kind: { type: 'string', required: true, indexSlot: 's2', validation: { enum: ['stripe', 'rest-json', 'vxil-project', 'webhook-source'] } },\n status: { type: 'string', indexSlot: 's3', validation: { enum: ['ok', 'stale', 'broken', 'could_not_check', 'paused'] } },\n owner: { type: 'string', indexSlot: 's4' }, // the person to ask\n consecutive_failures: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n poll_every_min: { type: 'int', indexSlot: 'n2', validation: { min: 1, max: 1440 } },\n last_ok_at: { type: 'datetime', indexSlot: 't1' },\n next_due_at: { type: 'datetime', indexSlot: 't2' }, // collect reads `next_due_at <= now`, sorted\n label: { type: 'string', validation: { max: 120 } },\n enabled: { type: 'bool', indexed: true },\n // base_url, path, series[] (name, json_path, unit, sensitive), source_id,\n // aggregates[], link_url \u2014 never a credential\n config: { type: 'json' },\n last_error: { type: 'text', validation: { max: 500 } },\n last_error_at: { type: 'datetime' },\n cursor: { type: 'string' }, // webhook sources: the newest event already in the feed\n },\n },\n\n // The trend store: one row per series per CLOSED hour (grain 'hour') and\n // one per day (grain 'day', written by compact-daily). Never raw samples.\n metric_points: {\n singular: 'metric_point',\n fields: {\n series: { type: 'string', required: true, indexSlot: 's1' }, // 'source:metric[:dim]'\n grain: { type: 'string', required: true, indexSlot: 's2', validation: { enum: ['hour', 'day'] } },\n bucket: { type: 'string', indexSlot: 's3' }, // derived (point_bucket)\n point_key: { type: 'string', unique: true, indexSlot: 's4' }, // derived (point_key)\n value: { type: 'float', indexSlot: 'n1' },\n restricted_value: { type: 'float', indexSlot: 'n2', readRoles: MONEY_READERS },\n ts: { type: 'datetime', required: true, indexSlot: 't1' }, // the bucket's start\n unit: { type: 'string' },\n samples: { type: 'int' },\n min: { type: 'float' },\n max: { type: 'float' },\n },\n },\n\n // The tiles: one row per series, patched on every collect tick.\n metric_latest: {\n singular: 'metric_latest',\n fields: {\n series: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n source: { type: 'string', indexSlot: 's2' }, // the integration key\n status: { type: 'string', indexSlot: 's3', validation: { enum: ['ok', 'warn', 'crit', 'stale'] } },\n last_bucket: { type: 'string', indexSlot: 's4' }, // the newest hour already in metric_points\n value: { type: 'float', indexSlot: 'n1' },\n restricted_value: { type: 'float', indexSlot: 'n2', readRoles: MONEY_READERS },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n label: { type: 'string' },\n unit: { type: 'string' },\n sensitive: { type: 'bool' }, // true \u21D2 the number lives in restricted_value only\n hour_samples: { type: 'int' },\n hour_min: { type: 'float' }, // non-sensitive series only\n hour_max: { type: 'float' },\n },\n },\n\n // The updates feed. Title + summary only \u2014 never a vendor payload, which\n // can carry personal data, and this collection's change-data is full.\n events_feed: {\n singular: 'event',\n fields: {\n source: { type: 'string', required: true, indexSlot: 's1' },\n kind: { type: 'string', indexSlot: 's2' },\n severity: { type: 'string', indexSlot: 's3', validation: { enum: ['info', 'warn', 'error'] } },\n ext_id: { type: 'string', unique: true, indexSlot: 's4' }, // the sender's event id \u21D2 a redelivery is a 409\n occurred_at: { type: 'datetime', indexSlot: 't1' },\n title: { type: 'string', validation: { max: 200 } },\n url: { type: 'string', validation: { max: 500 } },\n summary: { type: 'text', validation: { max: 2000 } },\n },\n },\n\n alert_rules: {\n singular: 'alert_rule',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n series: { type: 'string', required: true, indexSlot: 's2' },\n state: { type: 'string', indexSlot: 's3', validation: { enum: ['ok', 'firing'] } },\n severity: { type: 'string', indexSlot: 's4', validation: { enum: ['info', 'warn', 'crit'] } },\n threshold: { type: 'float', indexSlot: 'n1' }, // for 'stale': minutes without a fresh value\n for_minutes: { type: 'int', indexSlot: 'n2', validation: { min: 0 } },\n last_eval_at: { type: 'datetime', indexSlot: 't1' },\n last_fired_at: { type: 'datetime', indexSlot: 't2' },\n op: { type: 'string', required: true }, // > < >= <= stale (rule_op hook)\n enabled: { type: 'bool', indexed: true },\n channels: { type: 'json' }, // { chat: bool, email: bool }\n breach_since: { type: 'datetime' },\n repeat_after_hours: { type: 'int', validation: { min: 0 } },\n title: { type: 'string', validation: { max: 200 } },\n },\n },\n\n incidents: {\n singular: 'incident',\n fields: {\n rule: { type: 'string', required: true, indexSlot: 's1' },\n state: { type: 'string', required: true, indexSlot: 's2', validation: { enum: ['open', 'acked', 'resolved'] } },\n severity: { type: 'string', indexSlot: 's3' },\n acked_by: { type: 'string', indexSlot: 's4' },\n opened_at: { type: 'datetime', indexSlot: 't1' },\n resolved_at: { type: 'datetime', indexSlot: 't2' },\n title: { type: 'string', validation: { max: 200 } },\n last_value: { type: 'float' }, // null for a sensitive series\n note: { type: 'text', validation: { max: 2000 } },\n },\n },\n\n // Function memory: the `__compact__` row holds the last day compact-daily finished.\n ops_state: {\n singular: 'ops_state',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n cursor: { type: 'string' },\n },\n },\n },\n },\n\n // \u2500\u2500 The code that isn't config (guide ch. 8) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // PULL. Every 5 minutes: poll each due integration, write the tiles and the\n // closed hour. Also an http door \u2014 the \"Sync now\" button \u2014 that checks the\n // caller holds `integrations.sync` in the ops org first.\n collect: {\n entry: './functions/collect.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free: '*/15 * * * *'\n triggers: [{ kind: 'http' }],\n scopes: ['cms:read', 'cms:write', 'orgs:read'],\n // Every ref here must hold a value or the function refuses to run\n // (409 secret_missing): drop the sources you do not poll.\n secrets: ['secret:stripe_key', 'secret:shop_token', 'secret:vxil_peer_key'],\n // Deny-by-default egress: every REST host you poll goes here, then\n // `vxil push`. www.githubstatus.com is the keyless demo source. Which\n // host each SECRET may go to is fixed in functions/collect.ts\n // (SECRET_HOST / PEER_SECRETS), not by an integration row.\n egressAllow: ['api.stripe.com', 'shop.example.com', 'www.githubstatus.com'],\n limits: { timeoutMs: 20_000 }, // one slow vendor call fails in 20 s, not 30\n },\n\n // PUSH. Every minute: received webhooks \u2192 feed rows. And within seconds:\n // this project's own payments-integration events (the `payments.` trigger).\n 'drain-inbound': {\n entry: './functions/drain-inbound.ts',\n trigger: { kind: 'cron', schedule: '* * * * *' }, // Free: remove this trigger (3 crons max)\n triggers: [{ kind: 'webhook', source: 'payments.' }],\n // `webhooks:read` mints the function's confined webhooks token, which\n // reads received events (the inbound read routes) and nothing else.\n scopes: ['cms:read', 'cms:write', 'webhooks:read'],\n egressAllow: [],\n },\n\n // EVALUATE. Every 5 minutes: rules \xD7 latest values \u2192 incidents, once per crossing.\n 'evaluate-alerts': {\n entry: './functions/evaluate-alerts.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free: '*/15 * * * *'\n scopes: ['cms:read', 'cms:write', 'notifications:send', 'orgs:read'],\n secrets: ['secret:slack_webhook_url'],\n // The chat host. Add 'discord.com' for a Discord webhook or\n // 'chat.googleapis.com' for a Google Chat space webhook.\n egressAllow: ['hooks.slack.com'],\n },\n\n // Nightly: hour rows \u2192 one day row per series, then prune old hour rows.\n 'compact-daily': {\n entry: './functions/compact-daily.ts',\n trigger: { kind: 'cron', schedule: '15 0 * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // SHOW (the write half). The dashboard's only write door: ack/resolve an\n // incident, edit or toggle a rule, edit or pause an integration \u2014 each\n // gated on an org permission. Audited as the project; the person is\n // recorded in the row (acked_by).\n 'ops-action': {\n entry: './functions/ops-action.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'orgs:read'],\n egressAllow: [],\n },\n },\n\n // References only \u2014 values are set once with `vxil secrets set` and never appear here.\n secrets: {\n stripe_key: { feature: 'functions', description: 'Stripe RESTRICTED key, read-only on balance and charges' },\n shop_token: { feature: 'functions', description: 'bearer token of the REST API collect polls (rest-json integrations)' },\n vxil_peer_key: { feature: 'functions', description: 'API key of ANOTHER vxil project: usage:read (+ cms:read for aggregates)' },\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord / Google Chat space webhook) URL' },\n oidc_client_secret: { feature: 'auth', description: 'OIDC client secret of the staff identity provider' },\n },\n\n seed: {\n cms: [\n {\n collection: 'integrations',\n items: [\n {\n key: 'demo', kind: 'rest-json', label: 'Public status page (demo)', status: 'ok', enabled: true,\n poll_every_min: 5, consecutive_failures: 0, next_due_at: '2026-01-01T00:00:00Z',\n config: {\n base_url: 'https://www.githubstatus.com', path: '/api/v2/incidents/unresolved.json', auth: 'none',\n series: [{ name: 'open_incidents', json_path: 'incidents.length', unit: 'count' }],\n },\n },\n {\n key: 'stripe', kind: 'stripe', label: 'Payments provider', status: 'paused', enabled: false,\n poll_every_min: 5, consecutive_failures: 0, config: {},\n },\n {\n key: 'shop', kind: 'rest-json', label: 'Shop API', status: 'paused', enabled: false,\n poll_every_min: 15, consecutive_failures: 0,\n config: {\n base_url: 'https://shop.example.com', path: '/api/stats',\n series: [\n { name: 'orders_1h', json_path: 'orders.last_hour', unit: 'count' },\n { name: 'revenue_1h', json_path: 'revenue.last_hour', unit: 'usd', sensitive: true },\n ],\n },\n },\n {\n key: 'billing-project', kind: 'vxil-project', label: 'Billing backend (another vxil project)', status: 'paused',\n enabled: false, poll_every_min: 60, consecutive_failures: 0,\n config: { aggregates: [{ name: 'signups_24h', collection: 'accounts', fn: 'count', window_hours: 24 }] },\n },\n {\n key: 'support-inbox', kind: 'webhook-source', label: 'Support tool webhooks', status: 'paused', enabled: false,\n poll_every_min: 1, consecutive_failures: 0, config: { source_id: 'whs_replace_me' },\n },\n ],\n },\n {\n collection: 'alert_rules',\n items: [\n {\n key: 'payment-error-rate', series: 'stripe:charge_error_rate_1h', op: '>', threshold: 5, for_minutes: 10,\n severity: 'crit', state: 'ok', enabled: true, repeat_after_hours: 4, channels: { chat: true, email: true },\n title: 'Payment error rate above 5%',\n },\n {\n key: 'orders-dry', series: 'shop:orders_1h', op: '<', threshold: 1, for_minutes: 60,\n severity: 'warn', state: 'ok', enabled: true, repeat_after_hours: 24, channels: { chat: true, email: false },\n title: 'No orders in the last hour',\n },\n {\n key: 'demo-stale', series: 'demo:open_incidents', op: 'stale', threshold: 30, for_minutes: 0,\n severity: 'warn', state: 'ok', enabled: true, repeat_after_hours: 24, channels: { chat: true, email: false },\n title: 'Status data older than 30 minutes',\n },\n ],\n },\n ],\n },\n});\n",
19987
+ "readme": '# Ops dashboard (ops)\n\nA **private dashboard for one team**, wired to the team\'s own services and stores: your payments provider, a\nshop or any REST API, your other vxil projects, and the webhooks those services send you. It shows **live KPI\ntiles**, **hourly and daily trends**, **one updates feed**, **threshold alerts that open incidents** (once per\ncrossing, with a chat and an e-mail message), and **team roles** \u2014 revenue-grade numbers are readable only by the\nroles you name. Staff sign in through your identity provider and nothing else.\n\nIt is distribution over shipped building blocks. There is **no connectors feature**: a connector is a function +\na secret + a host in `egressAllow`. There is no dashboard engine either: every tile, point, feed entry and\nincident is a cms row, and your own frontend renders them \u2014 the reference frontend is the\n`team-dashboard` showcase app (https://vxil.com/showcase, downloadable as a zip).\n\nWhat it teaches that no other blueprint does: **time series on the cms** (closed-hour writes, a derived bucket,\nnightly compaction), **gated numbers** (`readRoles` + change-data that never carries them), and a **read-only\nbrowser** whose every write goes through one permission-checked function.\n\n## 1. The shape\n\n```\n your services \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 vxil project (this blueprint) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 your frontend\n (showcase app: team-dashboard)\n payments provider \u2500\u2510 PULL \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 metric_latest \u2500\u2500 tiles \u2500\u2500\u2510\n REST / shop API \u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25B6\u2502 collect \u2502 metric_points \u2500\u2500 trends \u2500\u2524 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n other vxil project \u2518 (cron) \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u251C\u2500\u2500\u25B6\u2502 read-only key \u2502\n \u25B2 compact-daily (nightly) \u2502 \u2502 + staff session \u2502\n inbound webhooks \u2500\u2500\u2510 PUSH \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502 \u2502 + realtime \u2502\n payments events \u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25B6\u2502 drain-inbound\u2502 events_feed \u2500\u2500 feed \u2500\u2500\u2524 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502 \u2502 every write\n EVALUATE \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 incidents \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u25BC\n alert_rules \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25B6\u2502 evaluate-alerts\u2502\u2500\u2500\u25B6 chat + e-mail \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502 ops-action \u2502 (org permission\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 checked first)\n```\n\nThe four arrows:\n\n| Arrow | Who | How |\n|---|---|---|\n| **Pull** | `collect`, every 5 minutes | polls each due integration, patches `metric_latest`, writes one `metric_points` row per closed hour |\n| **Push** | `drain-inbound`, every minute + on `payments.` events | received webhooks and the payments integration\'s events \u2192 `events_feed` |\n| **Evaluate** | `evaluate-alerts`, every 5 minutes | `alert_rules` \xD7 `metric_latest` \u2192 `incidents`, chat, e-mail \u2014 once per crossing |\n| **Show** | realtime change-data + your frontend | staff read every collection; the only write door is `ops-action` |\n\n## 2. Apply it\n\n```bash\nmkdir ops-dashboard && cd ops-dashboard && vxil init --template ops-dashboard # init scaffolds into the current directory\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\n# Free plan only: make \xA710\'s cron edits in vxil.config.ts now (4 crons, one every minute, do not fit Free)\n# the secrets (values never touch the config) \u2014 every ref a function declares must hold a value (below):\nprintf \'%s\' "$OIDC_SECRET" | vxil secrets set auth/oidc_client_secret\nprintf \'%s\' "$STRIPE_RK" | vxil secrets set functions/stripe_key # a RESTRICTED, read-only key\nprintf \'%s\' "$SHOP_TOKEN" | vxil secrets set functions/shop_token\nprintf \'%s\' "$PEER_KEY" | vxil secrets set functions/vxil_peer_key # a key of ANOTHER project\nprintf \'%s\' "$SLACK_URL" | vxil secrets set functions/slack_webhook_url\nvxil push # collections, hooks, change-data, the five functions\nvxil seed # the demo integration + three example rules\nvxil functions invoke collect # one tick by hand: the demo source fills its first tile\n```\n\n**Every secret a function declares must hold a value**, or that function refuses to run\n(`409 secret_missing`, naming the ref). Before the push, delete from `collect`\'s `secrets` the sources you do\nnot poll (and their `egressAllow` hosts) \u2014 or store a placeholder value for a source you will add later.\n\nAlso replace the placeholders in `vxil.config.ts`: the OIDC `issuer`, `clientId` and\n`allowedDomains`, the `allowedRedirectOrigins` of your frontend, and `DASHBOARD_URL` in\n`functions/evaluate-alerts.ts`.\n\n| Secret | What it is | Least privilege |\n|---|---|---|\n| `oidc_client_secret` | your identity provider\'s client secret for this app | \u2014 |\n| `stripe_key` | a payments-provider **restricted** key | read on Balance and Charges, nothing else |\n| `shop_token` | the bearer token of the REST API a `rest-json` integration polls | a read-only token |\n| `vxil_peer_key` | an API key of **another** vxil project you own | `usage:read`, plus `cms:read` if you aggregate its collections |\n| `slack_webhook_url` | a chat incoming-webhook URL | \u2014 |\n\n`drain-inbound` needs no key: it declares `webhooks:read` and reads received events through its own scoped\ncallback. `vxil_peer_key` is minted with `vxil keys mint --name <name> --scopes <a,b>` in the project it\nbelongs to. `vxil keys mint` acts on your account, so it needs a dashboard session: run `vxil login` once\nfirst (a project key from `vxil quickstart` / `vxil link` is not enough). `vxil link --key` stores the key you give it in `~/.vxil/credentials.json` (so does `vxil login` with its session): treat that file as a secret \u2014 never commit it, copy it into an image or share it.\n\n## 3. Staff-only sign-in, and the roles\n\n`auth` here is **OIDC-only**: `methods.emailPassword` and `methods.magicLink` are off, and\n`providers.oidc.allowedDomains` lists your company domain. A sign-in whose verified e-mail is not on that list\nis refused (`403 oidc_domain_not_allowed`), and with no other method there is no other way in. **That is the\nstaff gate.** Do not turn magic link or e-mail sign-up back on in a project holding company data: these\ncollections have no owner field, every signed-in person reads all of them, so an open sign-up would let any\naddress on the internet read every number on the dashboard.\n\nRoles live in one org. Create it (slug `ops` \u2014 the functions look it up by slug) and the four custom roles once,\nwith a server key that holds `orgs:write`:\n\nEach person signs in once through your identity provider first \u2014 that sign-in creates their user id\n(start it with `vx.auth.oauth.startUrl(\'oidc\', { redirect_uri })`; vxil.com/docs/guide/06-feature-catalog,\n"Starting social or SSO sign-in from a browser app"). Then look ids up by e-mail. Trying it with no\nidentity provider yet? `vxil api POST /v1/users --data \'{"id":"usr_me","email":"you@yourco.com"}\'` creates a\nrow you can use as `$ME` (it cannot sign in, but the org and role checks work).\n\n```bash\nME=$(vxil api GET "/v1/users?email=you@yourco.com" | jq -r \'.users[0].id\')\nOPS=$(vxil api GET "/v1/users?email=operator@yourco.com" | jq -r \'.users[0].id\')\nORG=$(vxil api POST /v1/orgs --data \'{"slug":"ops","name":"Ops","owner_user_id":"\'$ME\'"}\' | jq -r .org_id)\nvxil api POST /v1/orgs/roles --data \'{"role_key":"ops-viewer","name":"Viewer","permissions":["dashboard.read"]}\'\nvxil api POST /v1/orgs/roles --data \'{"role_key":"operator","name":"Operator","permissions":["dashboard.read","incidents.manage","integrations.sync"]}\'\nvxil api POST /v1/orgs/roles --data \'{"role_key":"finance","name":"Finance","permissions":["dashboard.read","revenue.read"]}\'\nvxil api POST /v1/orgs/roles --data \'{"role_key":"ops-admin","name":"Ops admin","permissions":["dashboard.read","revenue.read","incidents.manage","integrations.sync","rules.manage","integrations.manage"]}\'\nvxil api POST /v1/orgs/$ORG/members --data \'{"user_id":"\'$OPS\'","role":"operator"}\'\n```\n\n`owner`, `admin`, `member` and `viewer` are built-in role names with fixed permission sets, which is why the custom ones are\n`ops-viewer` and `ops-admin`. The built-in `owner` holds every permission. The permission strings are yours \u2014\nvxil only answers whether a member holds one (`GET /v1/orgs/{org}/check`).\n\nWith `orgClaims` on, the member\'s role rides the session, and the IdP\'s `groups` claim (named in\n`claims.roles`) rides beside it: a group called `finance` in your identity provider satisfies the same gate as\nthe org role.\n\n## 4. The browser keys\n\nMint two keys in the dashboard under **Keys \u2192 New key** (or with `vxil keys mint` after `vxil login`):\n\n| Key | Class | Scopes | Why |\n|---|---|---|---|\n| `web-auth` | Server | `auth:signin` | signing in is how a session is obtained, so this key cannot require one |\n| `web-data` | Public / thin-client | `cms:read`, `realtime:write`, `functions:invoke` | refused by the edge without a valid staff session |\n\n`web-data` holds **no `cms:write`**. A staff member can read every collection, mint a realtime connect token\nfor a channel (that is what `realtime:write` is for on a thin-client key \u2014 publishing over REST is refused to\nan end-user session) and call functions. Every write \u2014 acknowledging an incident, editing a rule, pausing an\nintegration \u2014 is `POST /v1/fn/ops-action`, which asks orgs whether this person holds the permission before it\nwrites. The write is audited as the project (the function writes with the project\'s cms scope, so the audit\nrow does not name the person); the human is recorded in the row itself \u2014 `acked_by` on an incident:\n\n```ts\nawait vx.asEndUser(session).fn[\'ops-action\']({ op: \'ack\', incident_id: \'itm_\u2026\', note: \'looking\' });\n// 403 { error: \'role_required\', permission: \'incidents.manage\' } for a viewer\n```\n\n| `op` | Permission | Body |\n|---|---|---|\n| `ack`, `resolve` | `incidents.manage` | `{ incident_id, note? }` \u2014 a compare-and-set on the state the button showed |\n| `upsert_rule` | `rules.manage` | `{ rule: { key, series, op, threshold, for_minutes?, severity?, repeat_after_hours?, channels?, title? } }` |\n| `toggle_rule` | `rules.manage` | `{ key, enabled }` |\n| `update_integration` | `integrations.manage` | `{ integration: { key, label?, owner?, poll_every_min?, config? } }` |\n| `pause_integration` | `integrations.manage` | `{ key, paused }` |\n\n"Sync now" is `POST /v1/fn/collect { integration: \'<key>\' }`, gated on `integrations.sync`.\n\n`update_integration` strips every config key that looks like a credential (`key`, `token`, `secret`,\n`password`, `authorization`, \u2026) and every value that looks like a bearer token, and names what it dropped. A\ncredential is a project secret, set with `vxil secrets set`, never from a browser.\n\nThe three settings that decide **where a credential is sent** \u2014 `config.base_url`, `config.auth` and\n`config.peer` \u2014 are not editable through `ops-action` at all: a change answers `409 locked_config` and an edit\nthat leaves them out keeps the stored values. They change only with a server key (`vxil api PATCH\n/v1/cms/items/integrations/<id>`, or a re-seed). `collect` enforces the same thing on its side, in code: each\nsecret is bound to one host (`SECRET_HOST` in `functions/collect.ts` \u2014 `shop_token` \u2192 your API\'s host;\n`stripe_key` \u2192 the payments provider\'s API; peer keys \u2192 the vxil API only, and only the names in\n`PEER_SECRETS`). A row that would send a credential anywhere else turns `broken` without a request being made.\n\n## 5. Adding a source\n\nA source is three edits: a **case in the `adapter` switch** in `functions/collect.ts`, the **host in\n`egressAllow`**, and a **secret**. Then `vxil push`. The egress guard is deny-by-default: a host you forgot\nanswers `403` and the integration turns `broken` with `egress blocked: add <host> to egressAllow and push`.\n\nBefore writing code, try the generic `rest-json` kind \u2014 a row, no code:\n\n```json\n{ "key": "shop", "kind": "rest-json", "enabled": true, "poll_every_min": 5,\n "next_due_at": "2026-01-01T00:00:00Z",\n "config": { "base_url": "https://shop.example.com", "path": "/api/stats",\n "series": [ { "name": "orders_1h", "json_path": "orders.last_hour", "unit": "count" },\n { "name": "revenue_1h", "json_path": "revenue.last_hour", "unit": "usd", "sensitive": true } ] } }\n```\n\nIt sends `Authorization: Bearer <shop_token>` (`"auth": "none"` for a public endpoint) and reads each\n`json_path` (`a.b.0.c`; a trailing `.length` counts an array). Series are named `<integration key>:<name>`.\nThe token goes **only** to the host in `SECRET_HOST.shop_token` at the top of `functions/collect.ts` \u2014 set it to\nyour API\'s host before the push; a `rest-json` row with a token pointing at any other host is refused. A second\nauthenticated REST API is a second secret and its own `case` (below), never a second row reusing `shop_token`.\n\n**A REST API with its own token** \u2014 one secret per vendor, one case:\n\n```ts\ncase \'helpdesk\': {\n const r = await getJson(\'https://api.helpdesk.example/v2/tickets/count?status=open\',\n { authorization: `Bearer ${secrets.helpdesk_token}` }) as { count: number };\n return [{ series: \'helpdesk:open_tickets\', value: r.count, unit: \'count\' }];\n}\n```\n\n**An analytics API that wants a service-account JWT** \u2014 sign it with WebCrypto inside the function (no SDK, no\nNode), then trade it for an access token:\n\n```ts\nasync function serviceToken(sa: { client_email: string; private_key: string }, scope: string): Promise<string> {\n const b64 = (s: string | ArrayBuffer) => btoa(typeof s === \'string\' ? s : String.fromCharCode(...new Uint8Array(s)))\n .replace(/=+$/, \'\').replace(/\\+/g, \'-\').replace(/\\//g, \'_\');\n const now = Math.floor(Date.now() / 1000);\n const unsigned = `${b64(JSON.stringify({ alg: \'RS256\', typ: \'JWT\' }))}.${b64(JSON.stringify({\n iss: sa.client_email, scope, aud: \'https://oauth2.example.com/token\', iat: now, exp: now + 3600 }))}`;\n const der = Uint8Array.from(atob(sa.private_key.replace(/-----[^-]+-----|\\s/g, \'\')), (c) => c.charCodeAt(0));\n const key = await crypto.subtle.importKey(\'pkcs8\', der, { name: \'RSASSA-PKCS1-v1_5\', hash: \'SHA-256\' }, false, [\'sign\']);\n const sig = await crypto.subtle.sign(\'RSASSA-PKCS1-v1_5\', key, new TextEncoder().encode(unsigned));\n const res = await fetch(\'https://oauth2.example.com/token\', { method: \'POST\',\n headers: { \'content-type\': \'application/x-www-form-urlencoded\' },\n body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${unsigned}.${b64(sig)}` });\n return ((await res.json()) as { access_token: string }).access_token;\n}\n```\n\nStore the service-account JSON as one secret, and add both the token host and the API host to `egressAllow`.\n\n**A SQL query over an HTTPS SQL endpoint** \u2014 the function is the SQL client; vxil never connects to your\ndatabase. The pattern, the credential handling and the least-privilege database user are in\nguide chapter 8 (https://vxil.com/docs/guide/08-running-your-code-functions#your-own-database-from-a-function):\n\n```ts\ncase \'warehouse\': {\n const rows = await sql(secrets.database_url, \'select count(*)::int as n from orders where created_at > now() - interval \\\'1 hour\\\'\');\n return [{ series: \'warehouse:orders_1h\', value: rows[0].n, unit: \'count\' }];\n}\n```\n\n**A tool that pushes instead of being polled** \u2014 create an inbound webhook source and point the tool at its\nreceiver URL; then add an integration row of kind `webhook-source` with `config.source_id`:\n\n```ts\nconst { source_id, receiver_url_path } = await vx.webhooks.sources.create({ provider: \'generic\', name: \'support tool\' });\n// the receiver URL is returned once \u2014 paste it into the tool\'s webhook settings\n```\n\n## 6. Time series on the cms\n\n- **Write closed hours, not samples.** Every collect tick patches the series\' `metric_latest` row (value,\n `updated_at`, the hour\'s sample count and min/max). When a tick finds the stored sample belongs to an hour that\n has ended, it first writes that hour\'s closing sample as one `metric_points` row (`grain: \'hour\'`), then\n records the hour in `last_bucket`.\n- **The bucket is derived.** The `point_bucket` hook derives `bucket` from `ts` (`2026-10-04T13` for an hour,\n `2026-10-04` for a day) and `point_key` concatenates series, grain and bucket into a `unique` field \u2014 so a\n redelivered tick or two overlapping ticks produce a `409`, never a second point. Both are Lane-A `derive`\n hooks, not `validate`: the server computes the value on every write, so a writer that sends a wrong bucket (or\n none) is overwritten rather than refused, and no writer \u2014 `collect`, `compact-daily`, a backfill script \u2014 can\n file a point under the wrong hour. Keep them `derive` in any copy of this config.\n- **Compaction.** `compact-daily` aggregates yesterday\'s hour rows per series on the server (avg, min, max,\n count) into one `grain: \'day\'` row, then deletes hour rows older than 90 days and feed rows older than 30\n (the bounded filtered delete, 100 rows a call). Charts read `grain: \'hour\'` for the last days and\n `grain: \'day\'` beyond.\n- **The 50,000-row scan cap.** A server-side aggregate refuses (`422`) rather than scanning more than 50,000 rows.\n One day of hour rows is `series \xD7 24`, so compaction stays far below it even at 500 series; an aggregate over\n a year of raw 5-minute samples (105,120 per series) would not fit even for one series.\n- **Why not raw points.** Every cms write is an audited row and a change-data frame, and every call a function\n makes back into vxil counts as a request. A sample every 5 minutes is 288 rows a day per series; a closed hour\n is 24 \u2014 twelve times fewer writes, frames and requests, and twelve times fewer rows under every scan.\n\nThe config also shows (commented) the declarative twin of `compact-daily`, a `readModels` aggregate. It is left\noff because the function also compacts the gated value, writes into the collection the charts already read, and\nprunes.\n\n## 7. Sensitive numbers\n\nA series marked `sensitive` (revenue, balances) writes its number to `restricted_value`, which declares\n`readRoles: [\'finance\', \'ops-admin\', \'owner\']`; `value` stays null. A signed-in staff member without one of those\nroles gets the row with `restricted_value` **absent** and cannot filter, sort or group by it. Your server key\nand the functions (which run as the server) still see it, so alerts evaluate it \u2014 but a sensitive series\' number\nnever goes into a chat message, an e-mail or an incident row.\n\n**Change-data frames do not apply `readRoles`.** A `payload: \'full\'` frame carries the whole row to every\nsubscriber of the channel. So `metric_latest` and `incidents` publish **ids only** and the client re-reads the\nrow through the cms, where the gate applies; only `events_feed`, which has no gated field, publishes full rows.\nThe CI gate `cdc-gated-fields` refuses a full frame on any collection that declares a `readRoles` field.\n\n## 8. Alerts once per crossing\n\nA rule is `{ series, op, threshold, for_minutes, severity, repeat_after_hours, channels }`; `op` is `>`, `<`,\n`>=`, `<=` or `stale` (no fresh value for `threshold` minutes). `evaluate-alerts`:\n\n1. on a new breach stores `breach_since` and waits `for_minutes`;\n2. when the breach is sustained, creates the incident under `lock: \'incident:<rule>\'` with\n `guard: { filter: { rule, state: { $in: [\'open\', \'acked\'] } }, max: 1 }` \u2014 of two overlapping ticks exactly\n one gets a `201`, and only that one posts the chat message and sends the e-mail (the built-in `transactional`\n template, to every `ops` member holding `operator`, `ops-admin` or `owner`);\n3. stays quiet while it stays red, except one reminder every `repeat_after_hours`;\n4. if the incident create fails for any other reason (a `5xx`, a hook\'s `422`), leaves the rule `ok` with its\n `breach_since` and counts it in `failed` \u2014 the next tick crosses again, so the alert is delayed, never lost;\n5. on recovery flips the rule back (an `If-Match` on its version picks the one tick that does it), resolves the\n open incident and posts one RESOLVED message.\n\nThe chat poster sends Slack\'s `text` and Discord\'s `content`; for Discord add `discord.com` to `egressAllow`; a\nGoogle Chat space webhook (`chat.googleapis.com`, also added to `egressAllow`) gets `{ text }` alone.\n\n## 9. Reading your other vxil projects\n\nAn integration of kind `vxil-project` reads another project with **that project\'s own key** \u2014 one key per\nproject, minted there with the least it needs (`usage:read` for its request meter, `cms:read` to aggregate its\ncollections). It is pull, not push: the other project does not know this dashboard exists, and revoking the key\nthere disconnects it. A second project gets a second secret: declare `secret:<name>` on `collect`, add the name\nto `PEER_SECRETS` in `functions/collect.ts`, and point the row at it with `config.peer` (any name not on that\nlist is refused, so a row cannot pick some other secret and send it as a bearer key).\n\n```json\n{ "key": "billing-project", "kind": "vxil-project", "enabled": true, "poll_every_min": 60,\n "config": { "aggregates": [ { "name": "signups_24h", "collection": "accounts", "fn": "count", "window_hours": 24 } ] } }\n```\n\n## 10. Limits and plans\n\n| | Free (staging / development projects) | Developer |\n|---|---|---|\n| Functions / crons | 5 functions, 3 crons, fastest every 15 min | 50 functions, 20 crons, every minute |\n| As shipped | set the two `*/5` schedules (`collect`, `evaluate-alerts`) to `*/15`; drop `drain-inbound`\'s cron trigger (its `payments.` trigger stays) | runs as shipped |\n| Requests a month | 100,000 | 1,000,000 |\n\nEvery call a function makes back into vxil counts as a request (vendor calls do not). The arithmetic for 3\npolled integrations \xD7 5 series, a 30-day month:\n\n| Function | Per run | Runs a month | Requests |\n|---|---|---|---|\n| `collect` every 5 min | 1 list + per integration (1 list + 5 tile patches + 1 status patch) = 22 | 8,640 | ~190,000 |\n| hour closes | 15 series \xD7 1 point | 720 hours | ~11,000 |\n| `evaluate-alerts` every 5 min | 1 rules read + 1 latest read (+ writes on crossings only) | 8,640 | ~17,000 |\n| `drain-inbound` every minute, 1 source | 1 list + 1 events read (+ 1 write per event) | 43,200 | ~86,000 + events |\n| `compact-daily` | 2 aggregates + 15 day rows + retention deletes | 30 | ~1,500 |\n\nAbout **305,000 requests a month** on Developer. On Free, at 15-minute cadence with 2 integrations \xD7 4 series and\nno inbound drain: `collect` ~37,000, `evaluate-alerts` ~6,000, hour closes ~6,000 \u2014 about **50,000**. A tile list\nread by your frontend counts too, so let realtime change-data tell the page when to re-read instead of polling.\n\n## 11. Compose with\n\n- **alerts-to-slack** \u2014 alerts on the platform\'s own failure events (a dead-lettered job, a quarantined\n function, a failing payments webhook) next to this dashboard\'s business thresholds.\n- **service-monitor** \u2014 uptime checks for your endpoints, written into the same kind of rows.\n- **team-workspace** \u2014 the full org model: invitations, per-resource grants, a field only finance receives.\n\n## 12. Where the frontend lives\n\nThe **App builder** builds public-facing apps. An internal dashboard is your own frontend \u2014 any framework,\nany host \u2014 reading this project with the two keys above. The reference is the `team-dashboard` showcase app (https://vxil.com/showcase):\nsign-in through your identity provider, tiles that re-read on change-data frames, trend charts over\n`metric_points`, the feed, and the incident buttons calling `ops-action`.\n',
19375
19988
  "functions": {
19376
19989
  "collect.ts": "// collect.ts \u2014 PULL: poll the team's services into the tiles and the hourly trend\n// (a vxil function; cron every 5 minutes + an http \"Sync now\" door).\n//\n// There is no connectors feature. A connector is the `adapter` switch below +\n// a secret + a host in egressAllow. Each tick:\n// 1. reads the enabled integrations whose next_due_at has passed (oldest\n// first, at most MAX_PER_TICK);\n// 2. runs that integration's adapter \u2014 stripe, rest-json or vxil-project \u2014\n// which returns samples: { series, value, unit, sensitive };\n// 3. per series: when the hour of the stored sample has CLOSED, writes that\n// hour's closing sample as ONE metric_points row (grain 'hour'; the unique\n// point_key makes a redelivered tick a 409, never a duplicate) and records\n// it in last_bucket; then PATCHes metric_latest with the new sample (the\n// first sighting creates it);\n// 4. PATCHes the integration: ok + next_due_at, or broken + the error.\n//\n// A sensitive series (revenue, balances) writes `restricted_value` \u2014 readable\n// only by the roles in the config's readRoles \u2014 and leaves `value` null.\n//\n// A vendor failure never throws: the integration is marked broken (failures\n// counted, last_error kept to 500 chars) and the tick answers 200, so one\n// vendor's outage does not trip the function's circuit breaker.\n//\n// \"Sync now\" (http): `POST /v1/fn/collect { integration: '<key>' }` with a\n// signed-in staff session. The caller must hold `integrations.sync` in the ops\n// org (checked with orgs, not trusted from the browser). The http run updates\n// the tiles; a series whose hour has closed is left to the next cron tick,\n// which can read the restricted value it needs to close that hour.\n// A server caller (no session \u2014 `vxil functions invoke collect`, your backend)\n// runs one named integration, or the whole tick when none is named.\n//\n// cron-walk: drains-filter \u2014 each processed integration is PATCHed with next_due_at = now + poll_every_min, so it leaves the read filter\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_PER_TICK = 20; // integrations per tick\nconst MAX_SERIES_PER_SOURCE = 25; // samples one adapter may emit\nconst ERROR_MAX = 500;\nconst OPS_ORG_SLUG = 'ops'; // the one org every staff member joins (README step 3)\nconst VENDOR_TIMEOUT_MS = 8_000;\n\n// \u2500\u2500 credentials are bound to hosts HERE, in code \u2014 never by a row \u2500\u2500\n// An integration row is data a dashboard admin can edit, and its base_url may\n// name any host in egressAllow. So a row never decides where a credential goes:\n// each secret is sent to the ONE host written below, and a row that points it\n// anywhere else turns `broken` without a request being made.\n// shop_token \u2192 the host of your REST API (edit to yours; the demo row uses \"auth\": \"none\")\n// stripe_key \u2192 api.stripe.com, hardcoded in its adapter\n// peer keys \u2192 this platform's own API only, and only the names listed in PEER_SECRETS\nconst SECRET_HOST: Record<string, string> = { shop_token: 'shop.example.com' };\n// The secrets a `vxil-project` row may name in config.peer. A second project =\n// a second `secret:<name>` on collect AND its name here.\nconst PEER_SECRETS: readonly string[] = ['vxil_peer_key'];\n\ntype Envelope = CronFunctionEnvelope | HttpFunctionEnvelope<{ integration?: string }>;\n\ninterface SeriesDef { name: string; json_path: string; unit?: string; sensitive?: boolean; label?: string }\ninterface AggregateDef { name: string; collection: string; fn: 'count' | 'sum' | 'avg' | 'min' | 'max'; field?: string; filter?: Record<string, unknown>; window_hours?: number; sensitive?: boolean; unit?: string }\ninterface IntegrationConfig {\n base_url?: string; path?: string; auth?: 'bearer' | 'none'; series?: SeriesDef[];\n aggregates?: AggregateDef[]; peer?: string;\n}\ninterface Integration {\n key: string; kind: string; status?: string; enabled?: boolean; poll_every_min?: number;\n consecutive_failures?: number; config?: IntegrationConfig; label?: string;\n}\ninterface Latest {\n series: string; source?: string; status?: string; last_bucket?: string | null; value?: number | null;\n restricted_value?: number | null; updated_at?: string; label?: string; unit?: string; sensitive?: boolean;\n hour_samples?: number; hour_min?: number | null; hour_max?: number | null;\n}\ninterface Item<T> { item_id: string; version?: number; data: T }\ninterface Sample { series: string; value: number; unit?: string | undefined; label?: string | undefined; sensitive?: boolean | undefined }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cmsJwt = env.scoped_jwts?.cms;\n if (!cmsJwt) return json({ error: 'missing cms scope' }, 403);\n const cms = new Cms(base, cmsJwt);\n const now = new Date();\n\n // \u2500\u2500 the http door \u2500\u2500\n // a signed-in staff member (\"Sync now\"): checked against the ops org, one integration\n // a server caller (`vxil functions invoke collect`, your own backend): one\n // integration when named, otherwise exactly what a cron tick does\n const key = env.trigger === 'http' && typeof env.payload?.integration === 'string' ? env.payload.integration : '';\n if (env.trigger === 'http' && (env.end_user || key)) {\n const user = env.end_user;\n if (user && !(await orgAllows(base, env.scoped_jwts?.orgs, user.id, 'integrations.sync'))) {\n return json({ error: 'role_required', permission: 'integrations.sync' }, 403);\n }\n const row = key ? (await cms.list<Integration>('integrations', { key }, { limit: 1 })).items[0] : undefined;\n if (!row) return json({ error: 'unknown_integration' }, 404);\n if (row.data.kind === 'webhook-source') return json({ error: 'webhook sources are drained, not polled' }, 422);\n const out = await pollOne(cms, env.secrets ?? {}, base, row, now, user ? 'http' : 'cron');\n return json(out, 200);\n }\n\n // \u2500\u2500 the cron tick \u2500\u2500\n const due = await cms.list<Integration>('integrations', {\n enabled: true,\n kind: { $in: ['stripe', 'rest-json', 'vxil-project'] },\n next_due_at: { $lte: now.toISOString() },\n }, { sort: 'next_due_at', limit: MAX_PER_TICK });\n const results = [];\n for (const row of due.items) results.push(await pollOne(cms, env.secrets ?? {}, base, row, now, 'cron'));\n return json({\n polled: results.length,\n ok: results.filter((r) => r.ok).length,\n broken: results.filter((r) => !r.ok).map((r) => r.integration),\n series: results.reduce((n, r) => n + r.series, 0),\n points: results.reduce((n, r) => n + r.points, 0),\n // more were due than one tick takes; the rest are first in line next tick\n truncated: due.items.length === MAX_PER_TICK,\n }, 200);\n },\n};\n\n/** One integration: adapter \u2192 samples \u2192 tiles + closed hours \u2192 the integration's own row. */\nasync function pollOne(\n cms: Cms, secrets: Record<string, string>, base: string, row: Item<Integration>, now: Date, mode: 'cron' | 'http',\n): Promise<{ integration: string; ok: boolean; series: number; points: number; deferred: number; error?: string }> {\n const integ = row.data;\n const nextDue = new Date(now.getTime() + Math.max(1, integ.poll_every_min ?? 5) * 60_000).toISOString();\n let samples: Sample[];\n try {\n samples = (await adapter(integ, secrets, base, now)).slice(0, MAX_SERIES_PER_SOURCE);\n } catch (e) {\n const missing = e instanceof MissingSecret;\n const error = String((e as Error).message ?? e).slice(0, ERROR_MAX);\n // If-Match: the row was read this tick, so the failure count stays exact\n // when two ticks overlap (the loser's PATCH is a 409 and changes nothing).\n await cms.patch('integrations', row.item_id, {\n status: missing ? 'could_not_check' : 'broken',\n consecutive_failures: (integ.consecutive_failures ?? 0) + 1,\n last_error: error,\n last_error_at: now.toISOString(),\n next_due_at: nextDue,\n }, row.version);\n return { integration: integ.key, ok: false, series: 0, points: 0, deferred: 0, error };\n }\n\n const w = await writeSamples(cms, integ.key, samples, now, mode);\n await cms.patch('integrations', row.item_id, {\n status: 'ok', consecutive_failures: 0, last_ok_at: now.toISOString(), last_error: null, next_due_at: nextDue,\n }, row.version);\n return { integration: integ.key, ok: true, series: w.series, points: w.points, deferred: w.deferred };\n}\n\n/** Write one source's samples: close finished hours, then update the tiles. */\nasync function writeSamples(\n cms: Cms, source: string, samples: Sample[], now: Date, mode: 'cron' | 'http',\n): Promise<{ series: number; points: number; deferred: number }> {\n const nowIso = now.toISOString();\n const hourNow = nowIso.slice(0, 13);\n const existing = new Map<string, Item<Latest>>();\n for (const it of (await cms.list<Latest>('metric_latest', { source }, { limit: 100 })).items) existing.set(it.data.series, it);\n\n let series = 0;\n let points = 0;\n let deferred = 0;\n for (const s of samples) {\n const cur = existing.get(s.series);\n const sensitive = Boolean(s.sensitive);\n const reading = sensitive ? { value: null, restricted_value: s.value } : { value: s.value, restricted_value: null };\n\n if (!cur) {\n // first sighting \u2014 a racing tick's create is a 409; the next tick patches it\n const r = await cms.create('metric_latest', {\n series: s.series, source, status: 'ok', label: s.label ?? s.series, unit: s.unit ?? null, sensitive,\n ...(sensitive ? { restricted_value: s.value } : { value: s.value, hour_min: s.value, hour_max: s.value }),\n hour_samples: 1, updated_at: nowIso,\n });\n if (r.ok) series++;\n continue;\n }\n\n const prevHour = (cur.data.updated_at ?? '').slice(0, 13);\n const hourClosed = prevHour !== '' && prevHour < hourNow;\n const patch: Record<string, unknown> = {\n ...reading, updated_at: nowIso, label: s.label ?? cur.data.label ?? s.series, unit: s.unit ?? cur.data.unit ?? null, sensitive,\n };\n if (cur.data.status === 'stale') patch.status = 'ok'; // fresh data again; alert severities are evaluate-alerts' to set\n\n if (hourClosed) {\n if (mode === 'http') { deferred++; continue; } // the cron closes this hour first\n if (cur.data.last_bucket !== prevHour) {\n // the closed hour's closing sample \u2192 ONE point (409 on point_key = already written)\n const wasSensitive = Boolean(cur.data.sensitive);\n const closing = wasSensitive ? cur.data.restricted_value : cur.data.value;\n if (typeof closing === 'number') {\n const p = await cms.create('metric_points', {\n series: s.series, grain: 'hour', ts: `${prevHour}:00:00.000Z`,\n // bucket + point_key are derived by the config's hooks; sent too so the\n // row is complete even where hooks are switched off\n bucket: prevHour, point_key: `${s.series}|hour|${prevHour}`,\n ...(wasSensitive\n ? { restricted_value: closing }\n : { value: closing, min: cur.data.hour_min ?? closing, max: cur.data.hour_max ?? closing }),\n samples: cur.data.hour_samples ?? 1, unit: cur.data.unit ?? null,\n });\n if (p.ok) points++;\n if (p.ok || p.status === 409) patch.last_bucket = prevHour;\n } else {\n patch.last_bucket = prevHour; // nothing to close (the stored value was gated or empty)\n }\n }\n patch.hour_samples = 1;\n patch.hour_min = sensitive ? null : s.value;\n patch.hour_max = sensitive ? null : s.value;\n } else {\n patch.hour_samples = (cur.data.hour_samples ?? 0) + 1;\n patch.hour_min = sensitive ? null : Math.min(cur.data.hour_min ?? s.value, s.value);\n patch.hour_max = sensitive ? null : Math.max(cur.data.hour_max ?? s.value, s.value);\n }\n const r = await cms.patch('metric_latest', cur.item_id, patch, cur.version);\n if (r.ok) series++;\n }\n return { series, points, deferred };\n}\n\n// \u2500\u2500 the adapters: one case per kind, self-contained (a blueprint is a clone) \u2500\u2500\n\nclass MissingSecret extends Error {}\n\nasync function adapter(integ: Integration, secrets: Record<string, string>, vxilBase: string, now: Date): Promise<Sample[]> {\n const cfg = integ.config ?? {};\n switch (integ.kind) {\n case 'stripe': {\n // A RESTRICTED key, read-only on Balance and Charges. Amounts are minor units.\n const key = secrets.stripe_key;\n if (!key) throw new MissingSecret('secret stripe_key is not set');\n const auth = { authorization: `Bearer ${key}` };\n const balance = await getJson(`https://api.stripe.com/v1/balance`, auth) as { available?: Array<{ amount: number; currency: string }> };\n const since = Math.floor(now.getTime() / 1000) - 3600;\n const charges = await getJson(`https://api.stripe.com/v1/charges?limit=100&created[gte]=${since}`, auth) as {\n data?: Array<{ amount: number; currency: string; status: string }>; has_more?: boolean;\n };\n const list = charges.data ?? [];\n const ok = list.filter((c) => c.status === 'succeeded');\n const failed = list.filter((c) => c.status === 'failed').length;\n const out: Sample[] = [\n { series: 'stripe:charges_1h', value: ok.length, unit: 'count', label: 'Charges (last hour)' },\n { series: 'stripe:failed_charges_1h', value: failed, unit: 'count', label: 'Failed charges (last hour)' },\n {\n series: 'stripe:charge_error_rate_1h', unit: '%', label: 'Charge error rate (last hour)',\n value: list.length === 0 ? 0 : Math.round((failed / list.length) * 1000) / 10,\n },\n ];\n const volume = new Map<string, number>();\n for (const c of ok) volume.set(c.currency, (volume.get(c.currency) ?? 0) + c.amount);\n for (const [cur, amount] of volume) {\n out.push({ series: `stripe:volume_1h:${cur}`, value: amount / 100, unit: cur, label: `Volume ${cur.toUpperCase()} (last hour)`, sensitive: true });\n }\n for (const b of balance.available ?? []) {\n out.push({ series: `stripe:balance:${b.currency}`, value: b.amount / 100, unit: b.currency, label: `Available ${b.currency.toUpperCase()}`, sensitive: true });\n }\n // more than 100 charges in the hour: the counts are a floor \u2014 page with\n // `starting_after` (and keep the cursor on the integration row) if you need exact ones\n return out;\n }\n\n case 'rest-json': {\n // Any JSON endpoint: GET base_url + path, pick numbers with json_path.\n if (!cfg.base_url || !cfg.series?.length) throw new Error('config needs base_url and series[]');\n let url: URL;\n try { url = new URL(`${cfg.base_url.replace(/\\/$/, '')}${cfg.path ?? ''}`); } catch { throw new Error('config base_url + path is not a URL'); }\n if (url.protocol !== 'https:') throw new Error('config base_url must be https://');\n const headers: Record<string, string> = { accept: 'application/json' };\n if (cfg.auth !== 'none') {\n // the host of the FINAL url (a path like \"@other.host\" cannot move it)\n if (url.host !== SECRET_HOST.shop_token) {\n throw new Error(`shop_token is bound to ${SECRET_HOST.shop_token} and is never sent to ${url.host}: set \"auth\": \"none\" for a public endpoint, or change SECRET_HOST in collect.ts`);\n }\n if (!secrets.shop_token) throw new MissingSecret('secret shop_token is not set');\n headers.authorization = `Bearer ${secrets.shop_token}`;\n }\n const body = await getJson(url.href, headers);\n const out: Sample[] = [];\n for (const s of cfg.series) {\n const v = pick(body, s.json_path);\n if (typeof v === 'number' && Number.isFinite(v)) {\n out.push({ series: `${integ.key}:${s.name}`, value: v, unit: s.unit, label: s.label ?? s.name, sensitive: s.sensitive });\n }\n }\n if (out.length === 0) throw new Error(`no numeric value at ${cfg.series.map((s) => s.json_path).join(', ')}`);\n return out;\n }\n\n case 'vxil-project': {\n // Another vxil project of yours, read with ITS key (one secret per project;\n // the least-privilege pattern \u2014 usage:read, plus cms:read for aggregates).\n const secretName = cfg.peer ?? 'vxil_peer_key';\n if (!PEER_SECRETS.includes(secretName)) {\n throw new Error(`config.peer '${secretName}' is not a peer secret: add it to PEER_SECRETS in collect.ts`);\n }\n const key = secrets[secretName];\n if (!key) throw new MissingSecret(`secret ${secretName} is not set`);\n const H = { authorization: `Bearer ${key}`, 'content-type': 'application/json' };\n const usage = await getJson(`${vxilBase}/v1/usage`, H) as { data?: { requests?: { used?: number } } };\n const out: Sample[] = [];\n if (typeof usage.data?.requests?.used === 'number') {\n out.push({ series: `${integ.key}:requests_mtd`, value: usage.data.requests.used, unit: 'requests', label: 'Requests this month' });\n }\n for (const a of cfg.aggregates ?? []) {\n const since = new Date(now.getTime() - (a.window_hours ?? 24) * 3600_000).toISOString();\n const r = await fetch(`${vxilBase}/v1/cms/items/${encodeURIComponent(a.collection)}/aggregate`, {\n method: 'POST', headers: H, signal: AbortSignal.timeout(VENDOR_TIMEOUT_MS),\n body: JSON.stringify({\n aggregates: [a.fn === 'count' ? { fn: 'count', as: 'v' } : { fn: a.fn, field: a.field, as: 'v' }],\n ...(a.filter ? { filter: a.filter } : {}),\n window: { field: 'created_at', since },\n }),\n });\n if (!r.ok) throw new Error(`aggregate ${a.collection}: ${r.status}`);\n const g = ((await r.json()) as { data?: { groups?: Array<{ v?: number }> } }).data?.groups?.[0];\n out.push({ series: `${integ.key}:${a.name}`, value: Number(g?.v ?? 0), unit: a.unit, label: a.name, sensitive: a.sensitive });\n }\n return out;\n }\n\n default:\n throw new Error(`no adapter for kind '${integ.kind}'`);\n }\n}\n\nasync function getJson(url: string, headers: Record<string, string>): Promise<unknown> {\n const r = await fetch(url, { headers, signal: AbortSignal.timeout(VENDOR_TIMEOUT_MS) });\n if (r.status === 403 && r.headers.get('x-vxil-egress') === 'blocked') {\n throw new Error(`egress blocked: add ${new URL(url).hostname} to egressAllow and push`);\n }\n if (!r.ok) throw new Error(`GET ${new URL(url).hostname}${new URL(url).pathname}: ${r.status}`);\n return r.json();\n}\n\n/** 'a.b.0.c' into a JSON value; a trailing '.length' counts an array. */\nfunction pick(root: unknown, path: string): unknown {\n let cur: unknown = root;\n for (const part of path.split('.')) {\n if (part === 'length' && Array.isArray(cur)) return cur.length;\n if (cur === null || typeof cur !== 'object') return undefined;\n cur = (cur as Record<string, unknown>)[part];\n }\n return cur;\n}\n\n/** Revocation-grade check: is this user allowed `permission` in the ops org? */\nasync function orgAllows(base: string, orgsJwt: string | undefined, userId: string, permission: string): Promise<boolean> {\n if (!orgsJwt) return false;\n const H = { authorization: `Bearer ${orgsJwt}` };\n const mine = await fetch(`${base}/v1/orgs?user_id=${encodeURIComponent(userId)}`, { headers: H });\n if (!mine.ok) return false;\n const org = ((await mine.json()) as { data?: { orgs?: Array<{ org_id: string; slug: string }> } }).data?.orgs\n ?.find((o) => o.slug === OPS_ORG_SLUG);\n if (!org) return false;\n const q = `user_id=${encodeURIComponent(userId)}&permission=${encodeURIComponent(permission)}`;\n const check = await fetch(`${base}/v1/orgs/${encodeURIComponent(org.org_id)}/check?${q}`, { headers: H });\n if (!check.ok) return false;\n return ((await check.json()) as { data?: { allowed?: boolean } }).data?.allowed === true;\n}\n\n/** The cms REST envelope: reads { data: { items: [{ item_id, version, data }], next_cursor } }. */\nclass Cms {\n constructor(private base: string, private jwt: string) {}\n private h(extra: Record<string, string> = {}) {\n return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json', ...extra };\n }\n async list<T>(coll: string, filter: Record<string, unknown>, o: { sort?: string; limit?: number } = {}): Promise<{ items: Item<T>[]; next_cursor: string | null }> {\n const q = new URLSearchParams({ filter: JSON.stringify(filter), limit: String(o.limit ?? 100) });\n if (o.sort) q.set('sort', o.sort);\n const r = await fetch(`${this.base}/v1/cms/items/${coll}?${q}`, { headers: this.h() });\n if (!r.ok) throw new Error(`cms list ${coll}: ${r.status}`);\n const d = ((await r.json()) as { data?: { items?: Item<T>[]; next_cursor?: string | null } }).data;\n return { items: d?.items ?? [], next_cursor: d?.next_cursor ?? null };\n }\n async create(coll: string, data: Record<string, unknown>): Promise<{ ok: boolean; status: number }> {\n const r = await fetch(`${this.base}/v1/cms/items/${coll}`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ status: 'published', data }),\n });\n return { ok: r.ok, status: r.status };\n }\n async patch(coll: string, id: string, data: Record<string, unknown>, version?: number): Promise<{ ok: boolean; status: number }> {\n const r = await fetch(`${this.base}/v1/cms/items/${coll}/${id}`, {\n method: 'PATCH',\n headers: this.h(version !== undefined ? { 'if-match': String(version) } : {}),\n body: JSON.stringify({ data }),\n });\n return { ok: r.ok, status: r.status };\n }\n}\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
19377
19990
  "compact-daily.ts": "// compact-daily.ts \u2014 hour rows \u2192 day rows, then retention (a vxil function;\n// cron '15 0 * * *', just after midnight UTC).\n//\n// 1. Two bounded server-side aggregates over YESTERDAY's hour rows\n// (POST /v1/cms/items/metric_points/aggregate, grouped by series, windowed\n// on the `ts` slot): avg/min/max/count of `value`, and avg/max of the gated\n// `restricted_value` (this function runs as the server, so it sees both).\n// 2. One grain 'day' row per series. The unique point_key ('series|day|date')\n// makes a re-run a 409 per row \u2014 the day is never written twice.\n// 3. Retention: hour rows older than RETAIN_HOUR_DAYS and feed rows older than\n// RETAIN_FEED_DAYS are removed with the bounded filtered delete (100 rows a\n// call, following next_cursor, at most MAX_DELETE_CALLS calls a night; the\n// remainder is reported as `truncated` and taken the next night).\n// 4. The `__compact__` row in ops_state records the last day finished.\n//\n// Why hour buckets and not raw samples: every cms write is an audited row and\n// a change-data frame. One closed hour per series is 24 rows a day; a raw\n// sample every 5 minutes would be 288 \u2014 twelve times the writes, the frames,\n// the request budget, and the rows the 50,000-row aggregate scan must cover.\n//\n// cron-walk: complete-per-tick \u2014 one aggregate per value kind is the whole read; the retention deletes follow next_cursor under MAX_DELETE_CALLS and report `truncated`\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\nconst RETAIN_HOUR_DAYS = 90;\nconst RETAIN_FEED_DAYS = 30;\nconst MAX_DELETE_CALLS = 30;\nconst MAX_GROUPS = 500;\n\ninterface Group { key: { series?: string }; avg?: number | null; min?: number | null; max?: number | null; n?: number }\ninterface Item<T> { item_id: string; version?: number; data: T }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as CronFunctionEnvelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cmsJwt = env.scoped_jwts?.cms;\n if (!cmsJwt) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const H = { authorization: `Bearer ${cmsJwt}`, 'content-type': 'application/json' };\n\n // the slot this tick was due for (stable across a late start or a re-delivery)\n const slot = new Date(env.scheduled_for ?? Date.now());\n const day = new Date(Date.UTC(slot.getUTCFullYear(), slot.getUTCMonth(), slot.getUTCDate() - 1)).toISOString().slice(0, 10);\n const since = `${day}T00:00:00.000Z`;\n const until = new Date(Date.parse(since) + 86_400_000).toISOString(); // exclusive\n\n const aggregate = async (field: 'value' | 'restricted_value'): Promise<Group[] | null> => {\n const r = await fetch(`${base}/v1/cms/items/metric_points/aggregate`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n aggregates: [\n { fn: 'avg', field, as: 'avg' }, { fn: 'min', field, as: 'min' },\n { fn: 'max', field, as: 'max' }, { fn: 'count', as: 'n' },\n ],\n groupBy: ['series'],\n filter: { grain: 'hour' },\n window: { field: 'ts', since, until },\n limit: MAX_GROUPS,\n }),\n });\n if (!r.ok) return null;\n return ((await r.json()) as { data?: { groups?: Group[] } }).data?.groups ?? [];\n };\n const plain = await aggregate('value');\n const gated = await aggregate('restricted_value');\n if (!plain || !gated) return Response.json({ error: 'aggregate_failed', day }, { status: 502 });\n\n // one day row per series; a series whose hours carried restricted_value is sensitive\n const bySeries = new Map<string, { plain?: Group; gated?: Group }>();\n for (const g of plain) if (g.key.series) bySeries.set(g.key.series, { plain: g });\n for (const g of gated) if (g.key.series) bySeries.set(g.key.series, { ...bySeries.get(g.key.series), gated: g });\n\n let written = 0;\n let existed = 0;\n for (const [series, { plain: p, gated: q }] of bySeries) {\n const sensitive = typeof q?.avg === 'number';\n if (!sensitive && typeof p?.avg !== 'number') continue;\n const r = await fetch(`${base}/v1/cms/items/metric_points`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n status: 'published',\n data: {\n series, grain: 'day', ts: since, bucket: day, point_key: `${series}|day|${day}`,\n samples: p?.n ?? q?.n ?? 0,\n ...(sensitive\n ? { restricted_value: round(q!.avg!) }\n : { value: round(p!.avg!), min: p!.min ?? null, max: p!.max ?? null }),\n },\n }),\n });\n if (r.ok) written++;\n else if (r.status === 409) existed++;\n }\n\n // retention \u2014 the bounded filtered delete, followed to the end or the cap\n const cutoff = (days: number) => new Date(Date.parse(since) - days * 86_400_000).toISOString();\n let calls = 0;\n const sweep = async (coll: string, filter: Record<string, unknown>): Promise<{ deleted: number; complete: boolean }> => {\n let deleted = 0;\n let cursor: string | null = null;\n while (calls < MAX_DELETE_CALLS) {\n calls++;\n const r = await fetch(`${base}/v1/cms/items/${coll}/delete`, {\n method: 'POST', headers: H,\n body: JSON.stringify({ filter, limit: 100, ...(cursor ? { cursor } : {}) }),\n });\n if (!r.ok) return { deleted, complete: false };\n const d = ((await r.json()) as { data?: { deleted?: number; complete?: boolean; next_cursor?: string | null } }).data ?? {};\n deleted += d.deleted ?? 0;\n // a call stopped by its time budget hands back next_cursor; otherwise the\n // filter simply re-matches what is left \u2014 loop while rows are still going\n cursor = d.complete === false ? (d.next_cursor ?? null) : null;\n if ((d.deleted ?? 0) === 0) return { deleted, complete: true };\n }\n return { deleted, complete: false };\n };\n const hours = await sweep('metric_points', { grain: 'hour', ts: { $lt: cutoff(RETAIN_HOUR_DAYS) } });\n const feed = await sweep('events_feed', { occurred_at: { $lt: cutoff(RETAIN_FEED_DAYS) } });\n\n // remember the finished day (create once, then PATCH)\n const sq = new URLSearchParams({ filter: JSON.stringify({ key: '__compact__' }), limit: '1' });\n const st = await fetch(`${base}/v1/cms/items/ops_state?${sq}`, { headers: H });\n const row = ((await st.json().catch(() => ({}))) as { data?: { items?: Item<{ cursor?: string }>[] } }).data?.items?.[0];\n const mark = { key: '__compact__', cursor: day, updated_at: new Date().toISOString() };\n if (row) {\n await fetch(`${base}/v1/cms/items/ops_state/${row.item_id}`, { method: 'PATCH', headers: H, body: JSON.stringify({ data: mark }) });\n } else {\n await fetch(`${base}/v1/cms/items/ops_state`, { method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data: mark }) });\n }\n\n return Response.json({\n day, series: bySeries.size, written, existed,\n groups_capped: plain.length >= MAX_GROUPS || gated.length >= MAX_GROUPS,\n pruned: { hour_rows: hours.deleted, feed_rows: feed.deleted },\n truncated: !hours.complete || !feed.complete, // retention resumes tomorrow\n });\n },\n};\n\nconst round = (n: number) => Math.round(n * 1000) / 1000;\n",
19378
- "drain-inbound.ts": "// drain-inbound.ts \u2014 PUSH: received webhooks and payments events \u2192 the updates\n// feed (a vxil function; cron every minute + a `payments.` event trigger).\n//\n// Two front doors, told apart by the envelope's `trigger`:\n//\n// cron For every enabled `webhook-source` integration (an inbound webhook\n// source you created with `POST /v1/webhooks/sources`), read the\n// events received since the integration's watermark and write one\n// events_feed row each. The received-events list is newest-first\n// with a `cursor` that pages OLDER, so the drain pages back until it\n// reaches the watermark (bounded by MAX_PAGES \u2014 a backlog past that\n// is reported as `gap`), writes oldest-first, then advances the\n// watermark on the integration row with If-Match.\n// The list is not on a function's scoped callback: it is read with\n// `vxil_read_key`, a key of THIS project holding only webhooks:read.\n//\n// webhook The project's own payments-integration events (`payments.*`)\n// arrive here within seconds, one invocation per event.\n//\n// Every row's ext_id is the sender's event id, declared unique: a redelivered\n// event, an overlapping tick or a lost watermark advance is a 409 \u2014 counted as\n// a duplicate, never a second row. Only a title and a short summary are kept:\n// a vendor payload can carry personal data, and this feed's change-data\n// frames are full rows.\n//\n// cron-walk: persisted-cursor \u2014 the newest drained event id is the integration row's `cursor` watermark, advanced with If-Match\n\nimport type { CronFunctionEnvelope, WebhookFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_SOURCES = 25;\nconst MAX_PAGES = 3; // \xD7 200 events per source per tick\nconst FIRST_RUN_EVENTS = 20; // a new source shows its latest 20, not its whole history\n\ntype Envelope = CronFunctionEnvelope | WebhookFunctionEnvelope;\ninterface Integration { key: string; kind: string; enabled?: boolean; cursor?: string | null; status?: string; config?: { source_id?: string; link_url?: string } }\ninterface Item<T> { item_id: string; version?: number; data: T }\ninterface Received { event_id: string; source_id: string; provider: string; provider_event_id: string | null; event_type: string | null; received_at: string; sig_verified?: boolean }\ninterface FeedRow { source: string; kind: string; severity: 'info' | 'warn' | 'error'; ext_id: string; occurred_at: string; title: string; url?: string | null; summary?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cmsJwt = env.scoped_jwts?.cms;\n if (!cmsJwt) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const H = { authorization: `Bearer ${cmsJwt}`, 'content-type': 'application/json' };\n\n // \u2500\u2500 the payments integration's own events, pushed \u2500\u2500\n if (env.trigger === 'webhook') {\n const p = env.payload;\n const d = (p.data && !('truncated' in p.data) ? p.data : {}) as Record<string, unknown>;\n const row: FeedRow = {\n source: 'payments',\n kind: p.event,\n severity: /failed|disputed|past_due|rejected/.test(p.event) ? (/failed|rejected/.test(p.event) ? 'error' : 'warn') : 'info',\n ext_id: `audit:${p.audit_id ?? env.idempotency_key}`,\n occurred_at: p.occurred_at ?? new Date().toISOString(),\n title: p.event.replace(/^payments\\./, 'payments: ').replace(/[._]/g, ' ').slice(0, 200),\n // provider and status only \u2014 never the end user, an amount (the feed is\n // ungated and its frames are full rows; revenue lives in restricted_value)\n // or the raw payload\n summary: [str(d.provider), str(d.status)].filter(Boolean).join(' \xB7 ').slice(0, 2000),\n };\n const res = await createFeed(base, H, row);\n return Response.json({ trigger: 'webhook', event: p.event, written: res === 'created', duplicate: res === 'duplicate' });\n }\n\n // \u2500\u2500 the cron drain \u2500\u2500\n const readKey = env.secrets?.vxil_read_key;\n if (!readKey) return Response.json({ error: 'missing secret vxil_read_key' }, { status: 409 });\n const q = new URLSearchParams({ filter: JSON.stringify({ kind: 'webhook-source', enabled: true }), limit: String(MAX_SOURCES) });\n const lr = await fetch(`${base}/v1/cms/items/integrations?${q}`, { headers: H });\n if (!lr.ok) return Response.json({ error: 'integrations_read_failed', status: lr.status }, { status: 502 });\n const sources = ((await lr.json()) as { data?: { items?: Item<Integration>[] } }).data?.items ?? [];\n\n const out = { sources: sources.length, written: 0, duplicates: 0, advanced: 0, gaps: [] as string[], failed: [] as string[] };\n for (const src of sources) {\n const sourceId = src.data.config?.source_id;\n if (!sourceId) continue;\n const watermark = src.data.cursor ?? null;\n const fresh: Received[] = [];\n let page: string | null = null;\n let reached = false;\n let ok = true;\n for (let i = 0; i < MAX_PAGES; i++) {\n const eq = new URLSearchParams({ source_id: sourceId, limit: watermark ? '200' : String(FIRST_RUN_EVENTS) });\n if (page) eq.set('cursor', page);\n const r = await fetch(`${base}/v1/webhooks/events?${eq}`, { headers: { authorization: `Bearer ${readKey}` } });\n if (!r.ok) { ok = false; break; }\n const d = ((await r.json()) as { data?: { events?: Received[]; next_cursor?: string | null } }).data;\n for (const e of d?.events ?? []) {\n // event ids sort by time; everything at or below the watermark is already in the feed\n if (watermark && e.event_id <= watermark) { reached = true; break; }\n fresh.push(e);\n }\n page = d?.next_cursor ?? null;\n if (reached || !page || !watermark) break; // first run: the newest page only\n }\n if (!ok) {\n out.failed.push(src.data.key);\n if (src.data.status !== 'broken') await patchIntegration(base, H, src, { status: 'broken', last_error: 'received-events read failed', last_error_at: new Date().toISOString() });\n continue;\n }\n if (watermark && !reached && page) out.gaps.push(src.data.key); // older than MAX_PAGES pages: skipped, reported\n\n for (const e of fresh.reverse()) { // oldest first, so the feed reads in order\n const res = await createFeed(base, H, {\n source: src.data.key,\n kind: (e.event_type ?? 'event').slice(0, 100),\n severity: /fail|error|dispute|declin|refund/i.test(e.event_type ?? '') ? 'warn' : 'info',\n ext_id: `${sourceId}:${e.provider_event_id ?? e.event_id}`.slice(0, 256),\n occurred_at: e.received_at,\n title: `${e.provider} ${e.event_type ?? 'event'}`.slice(0, 200),\n url: src.data.config?.link_url ?? null,\n summary: e.sig_verified === false ? 'unsigned delivery' : '',\n });\n if (res === 'created') out.written++;\n else if (res === 'duplicate') out.duplicates++;\n }\n\n const newest = fresh.length ? fresh[fresh.length - 1]!.event_id : null; // reversed: last = newest\n if (newest && (!watermark || newest > watermark)) {\n // advance the watermark \u2014 If-Match on the row's version: an overlapping\n // tick that already moved it wins, and the rows it wrote are 409s here\n const okAdv = await patchIntegration(base, H, src, {\n cursor: newest, status: 'ok', last_ok_at: new Date().toISOString(), consecutive_failures: 0,\n });\n if (okAdv) out.advanced++;\n }\n }\n return Response.json(out);\n },\n};\n\nasync function createFeed(base: string, H: Record<string, string>, row: FeedRow): Promise<'created' | 'duplicate' | 'failed'> {\n const r = await fetch(`${base}/v1/cms/items/events_feed`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data: row }),\n });\n if (r.ok) return 'created';\n return r.status === 409 ? 'duplicate' : 'failed';\n}\n\nasync function patchIntegration(base: string, H: Record<string, string>, row: Item<Integration>, data: Record<string, unknown>): Promise<boolean> {\n const r = await fetch(`${base}/v1/cms/items/integrations/${row.item_id}`, {\n method: 'PATCH',\n headers: row.version !== undefined ? { ...H, 'if-match': String(row.version) } : H,\n body: JSON.stringify({ data }),\n });\n return r.ok;\n}\n\nconst str = (v: unknown) => (typeof v === 'string' && v ? v : '');\n",
19991
+ "drain-inbound.ts": "// drain-inbound.ts \u2014 PUSH: received webhooks and payments events \u2192 the updates\n// feed (a vxil function; cron every minute + a `payments.` event trigger).\n//\n// Two front doors, told apart by the envelope's `trigger`:\n//\n// cron For every enabled `webhook-source` integration (an inbound webhook\n// source you created with `POST /v1/webhooks/sources`), read the\n// events received since the integration's watermark and write one\n// events_feed row each. The received-events list is newest-first\n// with a `cursor` that pages OLDER, so the drain pages back until it\n// reaches the watermark (bounded by MAX_PAGES \u2014 a backlog past that\n// is reported as `gap`), writes oldest-first, then advances the\n// watermark on the integration row with If-Match.\n// The list is read with the function's own confined webhooks token\n// (the declared `webhooks:read` scope; no stored key).\n//\n// webhook The project's own payments-integration events (`payments.*`)\n// arrive here within seconds, one invocation per event.\n//\n// Every row's ext_id is the sender's event id, declared unique: a redelivered\n// event, an overlapping tick or a lost watermark advance is a 409 \u2014 counted as\n// a duplicate, never a second row. Only a title and a short summary are kept:\n// a vendor payload can carry personal data, and this feed's change-data\n// frames are full rows.\n//\n// cron-walk: persisted-cursor \u2014 the newest drained event id is the integration row's `cursor` watermark, advanced with If-Match\n\nimport type { CronFunctionEnvelope, WebhookFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_SOURCES = 25;\nconst MAX_PAGES = 3; // \xD7 200 events per source per tick\nconst FIRST_RUN_EVENTS = 20; // a new source shows its latest 20, not its whole history\n\ntype Envelope = CronFunctionEnvelope | WebhookFunctionEnvelope;\ninterface Integration { key: string; kind: string; enabled?: boolean; cursor?: string | null; status?: string; config?: { source_id?: string; link_url?: string } }\ninterface Item<T> { item_id: string; version?: number; data: T }\ninterface Received { event_id: string; source_id: string; provider: string; provider_event_id: string | null; event_type: string | null; received_at: string; sig_verified?: boolean }\ninterface FeedRow { source: string; kind: string; severity: 'info' | 'warn' | 'error'; ext_id: string; occurred_at: string; title: string; url?: string | null; summary?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cmsJwt = env.scoped_jwts?.cms;\n if (!cmsJwt) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const H = { authorization: `Bearer ${cmsJwt}`, 'content-type': 'application/json' };\n\n // \u2500\u2500 the payments integration's own events, pushed \u2500\u2500\n if (env.trigger === 'webhook') {\n const p = env.payload;\n const d = (p.data && !('truncated' in p.data) ? p.data : {}) as Record<string, unknown>;\n const row: FeedRow = {\n source: 'payments',\n kind: p.event,\n severity: /failed|disputed|past_due|rejected/.test(p.event) ? (/failed|rejected/.test(p.event) ? 'error' : 'warn') : 'info',\n ext_id: `audit:${p.audit_id ?? env.idempotency_key}`,\n occurred_at: p.occurred_at ?? new Date().toISOString(),\n title: p.event.replace(/^payments\\./, 'payments: ').replace(/[._]/g, ' ').slice(0, 200),\n // provider and status only \u2014 never the end user, an amount (the feed is\n // ungated and its frames are full rows; revenue lives in restricted_value)\n // or the raw payload\n summary: [str(d.provider), str(d.status)].filter(Boolean).join(' \xB7 ').slice(0, 2000),\n };\n const res = await createFeed(base, H, row);\n return Response.json({ trigger: 'webhook', event: p.event, written: res === 'created', duplicate: res === 'duplicate' });\n }\n\n // \u2500\u2500 the cron drain \u2500\u2500\n const readKey = env.scoped_jwts?.webhooks;\n if (!readKey) return Response.json({ error: 'missing webhooks:read scope' }, { status: 403 });\n const q = new URLSearchParams({ filter: JSON.stringify({ kind: 'webhook-source', enabled: true }), limit: String(MAX_SOURCES) });\n const lr = await fetch(`${base}/v1/cms/items/integrations?${q}`, { headers: H });\n if (!lr.ok) return Response.json({ error: 'integrations_read_failed', status: lr.status }, { status: 502 });\n const sources = ((await lr.json()) as { data?: { items?: Item<Integration>[] } }).data?.items ?? [];\n\n const out = { sources: sources.length, written: 0, duplicates: 0, advanced: 0, gaps: [] as string[], failed: [] as string[] };\n for (const src of sources) {\n const sourceId = src.data.config?.source_id;\n if (!sourceId) continue;\n const watermark = src.data.cursor ?? null;\n const fresh: Received[] = [];\n let page: string | null = null;\n let reached = false;\n let ok = true;\n for (let i = 0; i < MAX_PAGES; i++) {\n const eq = new URLSearchParams({ source_id: sourceId, limit: watermark ? '200' : String(FIRST_RUN_EVENTS) });\n if (page) eq.set('cursor', page);\n const r = await fetch(`${base}/v1/webhooks/events?${eq}`, { headers: { authorization: `Bearer ${readKey}` } });\n if (!r.ok) { ok = false; break; }\n const d = ((await r.json()) as { data?: { events?: Received[]; next_cursor?: string | null } }).data;\n for (const e of d?.events ?? []) {\n // event ids sort by time; everything at or below the watermark is already in the feed\n if (watermark && e.event_id <= watermark) { reached = true; break; }\n fresh.push(e);\n }\n page = d?.next_cursor ?? null;\n if (reached || !page || !watermark) break; // first run: the newest page only\n }\n if (!ok) {\n out.failed.push(src.data.key);\n if (src.data.status !== 'broken') await patchIntegration(base, H, src, { status: 'broken', last_error: 'received-events read failed', last_error_at: new Date().toISOString() });\n continue;\n }\n if (watermark && !reached && page) out.gaps.push(src.data.key); // older than MAX_PAGES pages: skipped, reported\n\n for (const e of fresh.reverse()) { // oldest first, so the feed reads in order\n const res = await createFeed(base, H, {\n source: src.data.key,\n kind: (e.event_type ?? 'event').slice(0, 100),\n severity: /fail|error|dispute|declin|refund/i.test(e.event_type ?? '') ? 'warn' : 'info',\n ext_id: `${sourceId}:${e.provider_event_id ?? e.event_id}`.slice(0, 256),\n occurred_at: e.received_at,\n title: `${e.provider} ${e.event_type ?? 'event'}`.slice(0, 200),\n url: src.data.config?.link_url ?? null,\n summary: e.sig_verified === false ? 'unsigned delivery' : '',\n });\n if (res === 'created') out.written++;\n else if (res === 'duplicate') out.duplicates++;\n }\n\n const newest = fresh.length ? fresh[fresh.length - 1]!.event_id : null; // reversed: last = newest\n if (newest && (!watermark || newest > watermark)) {\n // advance the watermark \u2014 If-Match on the row's version: an overlapping\n // tick that already moved it wins, and the rows it wrote are 409s here\n const okAdv = await patchIntegration(base, H, src, {\n cursor: newest, status: 'ok', last_ok_at: new Date().toISOString(), consecutive_failures: 0,\n });\n if (okAdv) out.advanced++;\n }\n }\n return Response.json(out);\n },\n};\n\nasync function createFeed(base: string, H: Record<string, string>, row: FeedRow): Promise<'created' | 'duplicate' | 'failed'> {\n const r = await fetch(`${base}/v1/cms/items/events_feed`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data: row }),\n });\n if (r.ok) return 'created';\n return r.status === 409 ? 'duplicate' : 'failed';\n}\n\nasync function patchIntegration(base: string, H: Record<string, string>, row: Item<Integration>, data: Record<string, unknown>): Promise<boolean> {\n const r = await fetch(`${base}/v1/cms/items/integrations/${row.item_id}`, {\n method: 'PATCH',\n headers: row.version !== undefined ? { ...H, 'if-match': String(row.version) } : H,\n body: JSON.stringify({ data }),\n });\n return r.ok;\n}\n\nconst str = (v: unknown) => (typeof v === 'string' && v ? v : '');\n",
19379
19992
  "evaluate-alerts.ts": "// evaluate-alerts.ts \u2014 EVALUATE: rules \xD7 latest values \u2192 incidents, ONCE per\n// crossing (a vxil function; cron every 5 minutes).\n//\n// Each tick reads every enabled alert_rule (following next_cursor up to\n// MAX_RULES, and reporting `truncated` when there are more) and the\n// metric_latest rows they name (server mode: the restricted value is visible\n// here, never in a message). Per rule:\n//\n// breach = `op` against `threshold` \u2014 or, for op 'stale', no fresh value\n// for `threshold` minutes\n// ok \u2192 breach starts remember breach_since (no alert yet)\n// breach held for for_minutes ONE incident, created under a lock with a\n// guard (at most one open/acked incident per\n// rule), the rule flipped to `firing` (only once\n// an incident exists \u2014 a failed create leaves it\n// `ok` and the next tick retries), ONE chat\n// message, ONE mail+inbox message per on-call member\n// firing, still breached silence \u2014 a reminder every repeat_after_hours\n// firing \u2192 recovered the open incident resolved, ONE resolved message\n//\n// Every state change is a PATCH with If-Match on the rule's version, so two\n// overlapping ticks cannot both cross; the incident guard is the second fence.\n// A sensitive series never puts its number in a message or on the incident.\n//\n// cron-walk: complete-per-tick \u2014 every enabled rule is evaluated each tick (paged by next_cursor up to MAX_RULES, truncated reported)\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_RULES = 200;\nconst MAX_NOTIFY = 25; // on-call recipients per incident\nconst OPS_ORG_SLUG = 'ops';\nconst NOTIFY_ROLES = ['operator', 'ops-admin', 'owner'];\nconst DASHBOARD_URL = 'https://ops.example.com';\n\ntype Op = '>' | '<' | '>=' | '<=' | 'stale';\ninterface Rule {\n key: string; series: string; op: Op; threshold: number; for_minutes?: number; severity?: 'info' | 'warn' | 'crit';\n state?: 'ok' | 'firing'; enabled?: boolean; channels?: { chat?: boolean; email?: boolean };\n breach_since?: string | null; repeat_after_hours?: number; last_fired_at?: string | null; title?: string;\n}\ninterface Latest { series: string; value?: number | null; restricted_value?: number | null; sensitive?: boolean; updated_at?: string; status?: string; label?: string; unit?: string }\ninterface Incident { rule: string; state: string; severity?: string; title?: string }\ninterface Item<T> { item_id: string; version?: number | undefined; data: T }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as CronFunctionEnvelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cmsJwt = env.scoped_jwts?.cms;\n if (!cmsJwt) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const H = { authorization: `Bearer ${cmsJwt}`, 'content-type': 'application/json' };\n const now = new Date();\n const nowIso = now.toISOString();\n\n // 1. every enabled rule (complete per tick, capped)\n const rules: Item<Rule>[] = [];\n let cursor: string | null = null;\n let truncated = false;\n do {\n const q = new URLSearchParams({ filter: JSON.stringify({ enabled: true }), limit: '100' });\n if (cursor) q.set('cursor', cursor);\n const r = await fetch(`${base}/v1/cms/items/alert_rules?${q}`, { headers: H });\n if (!r.ok) return Response.json({ error: 'rules_read_failed', status: r.status }, { status: 502 });\n const d = ((await r.json()) as { data?: { items?: Item<Rule>[]; next_cursor?: string | null } }).data;\n rules.push(...(d?.items ?? []));\n cursor = d?.next_cursor ?? null;\n if (rules.length >= MAX_RULES) { truncated = cursor !== null; break; }\n } while (cursor);\n\n // 2. the latest value of every series they name ($in takes 50 values)\n const latest = new Map<string, Item<Latest>>();\n const names = [...new Set(rules.map((r) => r.data.series))];\n for (let i = 0; i < names.length; i += 50) {\n const q = new URLSearchParams({ filter: JSON.stringify({ series: { $in: names.slice(i, i + 50) } }), limit: '100' });\n const r = await fetch(`${base}/v1/cms/items/metric_latest?${q}`, { headers: H });\n if (!r.ok) continue;\n for (const it of ((await r.json()) as { data?: { items?: Item<Latest>[] } }).data?.items ?? []) latest.set(it.data.series, it);\n }\n\n const chat = env.secrets?.slack_webhook_url;\n const out = { rules: rules.length, fired: 0, resolved: 0, reminded: 0, pending: 0, unknown: 0, failed: 0, posted: 0, notified: 0, truncated };\n let recipients: string[] | null = null; // resolved once per tick, only when something fires\n\n for (const rule of rules) {\n const r = rule.data;\n const l = latest.get(r.series);\n const verdict = breached(r, l?.data, now);\n if (verdict === 'unknown') { out.unknown++; continue; } // no data yet for a threshold rule: could not check\n const sensitive = Boolean(l?.data.sensitive);\n const shown = sensitive ? null : (l?.data.value ?? null);\n const title = r.title ?? `${r.series} ${r.op} ${r.threshold}`;\n const state = r.state ?? 'ok';\n\n if (verdict && state === 'ok') {\n if (!r.breach_since) {\n await patchRule(base, H, rule, { breach_since: nowIso, last_eval_at: nowIso });\n if ((r.for_minutes ?? 0) > 0) { out.pending++; continue; }\n } else if (now.getTime() - Date.parse(r.breach_since) < (r.for_minutes ?? 0) * 60_000) {\n out.pending++;\n continue;\n }\n // THE CROSSING. The incident first: its lock + guard admit one open\n // incident per rule, so of two racing ticks exactly one gets a 201.\n const inc = await fetch(`${base}/v1/cms/items/incidents`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n status: 'published',\n lock: `incident:${r.key}`,\n guard: { filter: { rule: r.key, state: { $in: ['open', 'acked'] } }, max: 1 },\n data: { rule: r.key, state: 'open', severity: r.severity ?? 'warn', opened_at: nowIso, title, last_value: shown },\n }),\n });\n // The rule flips to `firing` only when an incident exists: a 201 (ours) or a\n // 409 guard_failed (one is already open). Any other answer \u2014 a 5xx, a hook's\n // 422, a 409 that is not the guard \u2014 leaves the rule `ok` with its\n // breach_since, so the next tick crosses again instead of losing the alert.\n const guardHeld = !inc.ok && inc.status === 409\n && ((await inc.clone().json().catch(() => ({}))) as { error?: { code?: string } }).error?.code === 'guard_failed';\n if (!inc.ok && !guardHeld) { out.failed++; continue; }\n // fresh version: patchRule above may have bumped it\n await patchRule(base, H, rule, { state: 'firing', last_fired_at: nowIso, last_eval_at: nowIso }, true);\n if (!inc.ok) continue; // guard_failed: an incident is already open \u2014 no second message\n out.fired++;\n await markTile(base, H, l, r.op === 'stale' ? 'stale' : r.severity === 'crit' ? 'crit' : 'warn');\n const value = shown === null ? '' : ` (now ${shown}${l?.data.unit ? ` ${l.data.unit}` : ''})`;\n if (r.channels?.chat !== false && chat) {\n if ((await post(chat, `${dot(r.severity)} [${(r.severity ?? 'warn').toUpperCase()}] ${title}${value} \u2014 ${DASHBOARD_URL}`)).ok) out.posted++;\n }\n if (r.channels?.email) {\n recipients ??= await onCall(base, env.scoped_jwts?.orgs);\n const incId = ((await inc.json().catch(() => ({}))) as { data?: { item_id?: string } }).data?.item_id ?? r.key;\n out.notified += await notify(base, env.scoped_jwts?.notifications, recipients, incId, title, value);\n }\n continue;\n }\n\n if (verdict && state === 'firing') {\n const every = r.repeat_after_hours ?? 0;\n const last = Date.parse(r.last_fired_at ?? '') || 0;\n if (every > 0 && now.getTime() - last >= every * 3600_000) {\n if (await patchRule(base, H, rule, { last_fired_at: nowIso, last_eval_at: nowIso }) && chat && r.channels?.chat !== false) {\n out.reminded++;\n if ((await post(chat, `\u23F0 STILL FIRING: ${title} (since ${r.breach_since ?? r.last_fired_at ?? '?'})`)).ok) out.posted++;\n }\n }\n continue;\n }\n\n if (!verdict && state === 'firing') {\n // RECOVERY \u2014 the If-Match on the rule picks the one tick that resolves\n if (!(await patchRule(base, H, rule, { state: 'ok', breach_since: null, last_eval_at: nowIso }))) continue;\n const q = new URLSearchParams({ filter: JSON.stringify({ rule: r.key, state: { $in: ['open', 'acked'] } }), limit: '5' });\n const open = await fetch(`${base}/v1/cms/items/incidents?${q}`, { headers: H });\n for (const inc of ((await open.json().catch(() => ({}))) as { data?: { items?: Item<Incident>[] } }).data?.items ?? []) {\n await fetch(`${base}/v1/cms/items/incidents/${inc.item_id}`, {\n method: 'PATCH', headers: H,\n body: JSON.stringify({ data: { state: 'resolved', resolved_at: nowIso }, if: { state: inc.data.state } }),\n });\n }\n out.resolved++;\n await markTile(base, H, l, 'ok');\n if (chat && r.channels?.chat !== false && (await post(chat, `\u{1F7E2} RESOLVED: ${title}`)).ok) out.posted++;\n continue;\n }\n\n if (!verdict && r.breach_since) {\n await patchRule(base, H, rule, { breach_since: null, last_eval_at: nowIso }); // cleared before it was sustained\n }\n }\n return Response.json(out);\n },\n};\n\n/** true = breached, false = fine, 'unknown' = no row for the series yet (or no value). */\nfunction breached(r: Rule, l: Latest | undefined, now: Date): boolean | 'unknown' {\n if (r.op === 'stale') {\n if (!l) return 'unknown'; // never reported: a missing source, not a stale one\n const at = Date.parse(l.updated_at ?? '');\n return !Number.isFinite(at) || now.getTime() - at > r.threshold * 60_000;\n }\n const v = l ? (l.sensitive ? l.restricted_value : l.value) : null;\n if (typeof v !== 'number') return 'unknown';\n switch (r.op) {\n case '>': return v > r.threshold;\n case '<': return v < r.threshold;\n case '>=': return v >= r.threshold;\n case '<=': return v <= r.threshold;\n default: return 'unknown';\n }\n}\n\n/** PATCH a rule with If-Match; `fresh` re-reads the version first. False on a 409. */\nasync function patchRule(base: string, H: Record<string, string>, rule: Item<Rule>, data: Partial<Rule> & Record<string, unknown>, fresh = false): Promise<boolean> {\n if (fresh) {\n const g = await fetch(`${base}/v1/cms/items/alert_rules/${rule.item_id}`, { headers: H });\n if (g.ok) rule.version = ((await g.json()) as { data?: { version?: number } }).data?.version ?? rule.version;\n }\n const res = await fetch(`${base}/v1/cms/items/alert_rules/${rule.item_id}`, {\n method: 'PATCH',\n headers: rule.version !== undefined ? { ...H, 'if-match': String(rule.version) } : H,\n body: JSON.stringify({ data }),\n });\n if (res.ok) {\n rule.version = ((await res.json().catch(() => ({}))) as { data?: { version?: number } }).data?.version ?? (rule.version ?? 0) + 1;\n Object.assign(rule.data, data);\n }\n return res.ok;\n}\n\n/** The tile's colour follows the rule; a crossing is the only write. */\nasync function markTile(base: string, H: Record<string, string>, l: Item<Latest> | undefined, status: string): Promise<void> {\n if (!l || l.data.status === status) return;\n await fetch(`${base}/v1/cms/items/metric_latest/${l.item_id}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data: { status } }),\n });\n}\n\n/** Members of the ops org holding an on-call role. */\nasync function onCall(base: string, orgsJwt: string | undefined): Promise<string[]> {\n if (!orgsJwt) return [];\n const H = { authorization: `Bearer ${orgsJwt}` };\n const orgs = await fetch(`${base}/v1/orgs`, { headers: H });\n const org = ((await orgs.json().catch(() => ({}))) as { data?: { orgs?: Array<{ org_id: string; slug: string }> } }).data?.orgs\n ?.find((o) => o.slug === OPS_ORG_SLUG);\n if (!org) return [];\n const m = await fetch(`${base}/v1/orgs/${encodeURIComponent(org.org_id)}/members`, { headers: H });\n const members = ((await m.json().catch(() => ({}))) as { data?: { members?: Array<{ user_id: string; role: string }> } }).data?.members ?? [];\n return members.filter((x) => NOTIFY_ROLES.includes(x.role)).map((x) => x.user_id).slice(0, MAX_NOTIFY);\n}\n\n/** One transactional e-mail + inbox message per recipient; the key makes a re-run a no-op. */\nasync function notify(base: string, jwt: string | undefined, users: string[], incidentId: string, title: string, value: string): Promise<number> {\n if (!jwt) return 0;\n let sent = 0;\n for (const user_id of users) {\n const r = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${jwt}`, 'content-type': 'application/json', 'idempotency-key': `incident:${incidentId}:${user_id}` },\n body: JSON.stringify({\n user_id, template: 'transactional', channel: 'both',\n data: { subject: `Alert: ${title}`, paragraph: `${title}${value}. Open the dashboard to acknowledge it.`, cta_label: 'Open the dashboard', cta_url: DASHBOARD_URL },\n }),\n });\n if (r.ok) sent++;\n }\n return sent;\n}\n\nconst dot = (s?: string) => (s === 'crit' ? '\u{1F534}' : s === 'warn' ? '\u{1F7E0}' : '\u{1F535}');\n\n// \u2500\u2500 the chat webhook: `text` is Slack's field, `content` is Discord's \u2014 send both,\n// EXCEPT to a Google Chat space webhook (chat.googleapis.com), which takes\n// `{ text }` alone \u2014 an unknown field can be rejected there, so it is dropped.\nasync function post(url: string, text: string): Promise<{ ok: boolean; status: number }> {\n let host = '';\n try { host = new URL(url).hostname; } catch { /* unparseable \u2192 the generic body below */ }\n const body = host === 'chat.googleapis.com' ? { text } : { text, content: text };\n const res = await fetch(url, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n }).catch(() => null);\n return { ok: Boolean(res?.ok), status: res?.status ?? 0 };\n}\n",
19380
19993
  "ops-action.ts": "// ops-action.ts \u2014 the dashboard's ONLY write door (a vxil function, http).\n//\n// The browser key is read-only (cms:read, no cms:write), so every staff write\n// arrives here as `POST /v1/fn/ops-action { op, \u2026 }` with the signed-in\n// person's session. Each op names the org permission it needs, and the\n// function asks orgs \u2014 the revocation-grade check, not the session snapshot \u2014\n// before it writes:\n//\n// ack | resolve incidents.manage compare-and-set on the state\n// upsert_rule | toggle_rule rules.manage the series must exist\n// update_integration | pause_integration integrations.manage\n// non-secret settings only\n//\n// A refusal is `403 { error: 'role_required', permission }`. The writes are\n// audited as the project (the function writes with the project's cms scope);\n// the person is recorded in the row itself \u2014 `acked_by` on an incident \u2014 and\n// was checked against orgs before anything was written.\n//\n// Integration settings never hold a credential: any key that looks like one\n// (key, token, secret, password, authorization) is stripped before the write\n// and named in the answer. Credentials are project secrets, set with\n// `vxil secrets set`, never from a browser. And the settings that decide WHERE\n// a credential goes \u2014 config.base_url, config.auth, config.peer \u2014 are not\n// editable here at all (409 locked_config): they change only with a server key\n// (`vxil api PATCH \u2026` or a re-seed), and collect.ts binds each secret to one host\n// in code besides.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst OPS_ORG_SLUG = 'ops';\nconst CREDENTIAL_KEY = /key|token|secret|password|passwd|authorization|credential/i;\nconst CREDENTIAL_VALUE = /^(?:bearer\\s|sk_|rk_|pk_live_|ghp_|xox[abp]-)/i;\n// where a credential goes: never changed from the browser\nconst LOCKED_CONFIG = ['base_url', 'auth', 'peer'] as const;\n\ntype Payload = {\n op?: string;\n incident_id?: string; note?: string;\n rule?: { key?: string; series?: string; op?: string; threshold?: number; for_minutes?: number; severity?: string; repeat_after_hours?: number; channels?: { chat?: boolean; email?: boolean }; enabled?: boolean; title?: string };\n key?: string; enabled?: boolean; paused?: boolean;\n integration?: { key?: string; label?: string; owner?: string; poll_every_min?: number; config?: Record<string, unknown> };\n};\ntype Env = HttpFunctionEnvelope<Payload>;\ninterface Item<T> { item_id: string; version?: number; data: T }\n\nconst PERMISSION: Record<string, string> = {\n ack: 'incidents.manage',\n resolve: 'incidents.manage',\n upsert_rule: 'rules.manage',\n toggle_rule: 'rules.manage',\n update_integration: 'integrations.manage',\n pause_integration: 'integrations.manage',\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 cmsJwt = env.scoped_jwts?.cms;\n if (!cmsJwt) return json({ error: 'missing cms scope' }, 403);\n const user = env.end_user;\n if (!user) return json({ error: 'sign_in_required' }, 401);\n const p = env.payload ?? {};\n const permission = PERMISSION[p.op ?? ''];\n if (!permission) return json({ error: 'unknown_op', ops: Object.keys(PERMISSION) }, 400);\n if (!(await orgAllows(base, env.scoped_jwts?.orgs, user.id, permission))) {\n return json({ error: 'role_required', permission }, 403);\n }\n const H = { authorization: `Bearer ${cmsJwt}`, 'content-type': 'application/json' };\n const now = new Date().toISOString();\n\n switch (p.op) {\n case 'ack':\n case 'resolve': {\n if (!p.incident_id) return json({ error: 'incident_id required' }, 400);\n const data = p.op === 'ack'\n ? { state: 'acked', acked_by: user.id, ...(p.note ? { note: p.note.slice(0, 2000) } : {}) }\n : { state: 'resolved', resolved_at: now, ...(p.note ? { note: p.note.slice(0, 2000) } : {}) };\n // compare-and-set: only from the state the button was showing\n const from = p.op === 'ack' ? ['open'] : ['open', 'acked'];\n const r = await fetch(`${base}/v1/cms/items/incidents/${encodeURIComponent(p.incident_id)}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data, if: { state: { $in: from } } }),\n });\n if (r.status === 409) return json({ error: 'state_changed', hint: 'someone else moved this incident; reload it' }, 409);\n if (!r.ok) return json({ error: 'write_failed', status: r.status }, r.status === 404 ? 404 : 502);\n return json({ ok: true, op: p.op, incident_id: p.incident_id, state: data.state }, 200);\n }\n\n case 'upsert_rule': {\n const r = p.rule ?? {};\n if (!r.key || !r.series || !r.op || typeof r.threshold !== 'number') {\n return json({ error: 'rule needs key, series, op and a numeric threshold' }, 400);\n }\n // the series must be one the dashboard already collects\n const seen = await list(base, H, 'metric_latest', { series: r.series }, 1);\n if (seen.length === 0) return json({ error: 'unknown_series', series: r.series }, 422);\n const data: Record<string, unknown> = {\n key: r.key, series: r.series, op: r.op, threshold: r.threshold,\n for_minutes: r.for_minutes ?? 0, severity: r.severity ?? 'warn', repeat_after_hours: r.repeat_after_hours ?? 24,\n channels: { chat: r.channels?.chat !== false, email: Boolean(r.channels?.email) },\n enabled: r.enabled !== false, title: (r.title ?? `${r.series} ${r.op} ${r.threshold}`).slice(0, 200),\n };\n const existing = (await list<{ key: string }>(base, H, 'alert_rules', { key: r.key }, 1))[0];\n const w = existing\n ? await fetch(`${base}/v1/cms/items/alert_rules/${existing.item_id}`, { method: 'PATCH', headers: H, body: JSON.stringify({ data }) })\n : await fetch(`${base}/v1/cms/items/alert_rules`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data: { ...data, state: 'ok' } }),\n });\n if (!w.ok) return json({ error: 'write_failed', status: w.status, detail: await errorCode(w) }, w.status === 422 ? 422 : 502);\n return json({ ok: true, op: p.op, key: r.key, created: !existing }, existing ? 200 : 201);\n }\n\n case 'toggle_rule': {\n if (!p.key || typeof p.enabled !== 'boolean') return json({ error: 'key and enabled required' }, 400);\n const row = (await list(base, H, 'alert_rules', { key: p.key }, 1))[0];\n if (!row) return json({ error: 'unknown_rule' }, 404);\n // switching a rule off also clears a pending breach; a firing rule's\n // open incident stays for a human to resolve\n const w = await fetch(`${base}/v1/cms/items/alert_rules/${row.item_id}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data: { enabled: p.enabled, ...(p.enabled ? {} : { breach_since: null }) } }),\n });\n return w.ok ? json({ ok: true, op: p.op, key: p.key, enabled: p.enabled }, 200) : json({ error: 'write_failed', status: w.status }, 502);\n }\n\n case 'update_integration': {\n const i = p.integration ?? {};\n if (!i.key) return json({ error: 'integration.key required' }, 400);\n const row = (await list<{ config?: Record<string, unknown> }>(base, H, 'integrations', { key: i.key }, 1))[0];\n if (!row) return json({ error: 'unknown_integration' }, 404);\n const stripped: string[] = [];\n const data: Record<string, unknown> = {};\n if (typeof i.label === 'string') data.label = i.label.slice(0, 120);\n if (typeof i.owner === 'string') data.owner = i.owner.slice(0, 120);\n if (typeof i.poll_every_min === 'number') data.poll_every_min = Math.max(1, Math.min(1440, Math.round(i.poll_every_min)));\n if (i.config && typeof i.config === 'object' && !Array.isArray(i.config)) {\n // base_url / auth / peer decide where a credential is sent: refuse a change,\n // and carry the stored values over (a config without `auth` must not\n // silently turn a public source into one that sends a token)\n const stored = row.data.config ?? {};\n const locked = LOCKED_CONFIG.filter((k) => k in i.config! && JSON.stringify(i.config![k]) !== JSON.stringify(stored[k]));\n if (locked.length > 0) {\n return json({ error: 'locked_config', fields: locked, hint: 'base_url, auth and peer change only with a server key' }, 409);\n }\n const next = stripCredentials(i.config, '', stripped);\n for (const k of LOCKED_CONFIG) { if (k in stored) next[k] = stored[k]; else delete next[k]; }\n data.config = next;\n }\n if (Object.keys(data).length === 0) return json({ error: 'nothing to update', stripped }, 400);\n const w = await fetch(`${base}/v1/cms/items/integrations/${row.item_id}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data }),\n });\n if (!w.ok) return json({ error: 'write_failed', status: w.status }, 502);\n return json({ ok: true, op: p.op, key: i.key, stripped }, 200);\n }\n\n case 'pause_integration': {\n if (!p.key || typeof p.paused !== 'boolean') return json({ error: 'key and paused required' }, 400);\n const row = (await list(base, H, 'integrations', { key: p.key }, 1))[0];\n if (!row) return json({ error: 'unknown_integration' }, 404);\n const data = p.paused\n ? { enabled: false, status: 'paused' }\n : { enabled: true, status: 'ok', next_due_at: now, consecutive_failures: 0 }; // resumed: polled on the next tick\n const w = await fetch(`${base}/v1/cms/items/integrations/${row.item_id}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data }),\n });\n return w.ok ? json({ ok: true, op: p.op, key: p.key, paused: p.paused }, 200) : json({ error: 'write_failed', status: w.status }, 502);\n }\n }\n return json({ error: 'unknown_op' }, 400);\n },\n};\n\n/** Drop every key (at any depth) that looks like a credential, and any value\n * that looks like a bearer token or a provider secret. Names what it dropped. */\nfunction stripCredentials(v: Record<string, unknown>, path: string, stripped: string[]): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const [k, val] of Object.entries(v)) {\n const at = path ? `${path}.${k}` : k;\n if (CREDENTIAL_KEY.test(k) || (typeof val === 'string' && CREDENTIAL_VALUE.test(val))) { stripped.push(at); continue; }\n if (Array.isArray(val)) {\n out[k] = val.map((x, i) => (x && typeof x === 'object' && !Array.isArray(x) ? stripCredentials(x as Record<string, unknown>, `${at}.${i}`, stripped) : x));\n } else if (val && typeof val === 'object') {\n out[k] = stripCredentials(val as Record<string, unknown>, at, stripped);\n } else {\n out[k] = val;\n }\n }\n return out;\n}\n\nasync function list<T = Record<string, unknown>>(base: string, H: Record<string, string>, coll: string, filter: Record<string, unknown>, limit: number): Promise<Item<T>[]> {\n const q = new URLSearchParams({ filter: JSON.stringify(filter), limit: String(limit) });\n const r = await fetch(`${base}/v1/cms/items/${coll}?${q}`, { headers: H });\n if (!r.ok) return [];\n return ((await r.json()) as { data?: { items?: Item<T>[] } }).data?.items ?? [];\n}\n\nasync function errorCode(r: Response): Promise<string | undefined> {\n return ((await r.json().catch(() => ({}))) as { error?: { code?: string; message?: string } }).error?.message;\n}\n\n/** Revocation-grade check: does this user hold `permission` in the ops org? */\nasync function orgAllows(base: string, orgsJwt: string | undefined, userId: string, permission: string): Promise<boolean> {\n if (!orgsJwt) return false;\n const H = { authorization: `Bearer ${orgsJwt}` };\n const mine = await fetch(`${base}/v1/orgs?user_id=${encodeURIComponent(userId)}`, { headers: H });\n if (!mine.ok) return false;\n const org = ((await mine.json()) as { data?: { orgs?: Array<{ org_id: string; slug: string }> } }).data?.orgs\n ?.find((o) => o.slug === OPS_ORG_SLUG);\n if (!org) return false;\n const q = `user_id=${encodeURIComponent(userId)}&permission=${encodeURIComponent(permission)}`;\n const check = await fetch(`${base}/v1/orgs/${encodeURIComponent(org.org_id)}/check?${q}`, { headers: H });\n if (!check.ok) return false;\n return ((await check.json()) as { data?: { allowed?: boolean } }).data?.allowed === true;\n}\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n"
19381
19994
  }
@@ -19431,13 +20044,11 @@ export default defineConfig({
19431
20044
  "functions"
19432
20045
  ],
19433
20046
  "hasFunctions": true,
19434
- "byoKeys": [
19435
- "vxil_read_key"
19436
- ],
19437
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Job Runner\" \u2014 the BACKGROUND-WORK blueprint. Four ways work leaves the\n// request path, and what happens when it fails:\n//\n// \u2022 enqueue \u2192 one-off work, deduplicated by an idempotency key, and\n// optionally deferred (seconds from now, or an exact time)\n// \u2022 schedules \u2192 cron, with a LATE schedule reporting itself\n// \u2022 queue trigger \u2192 a deployed function that IS the worker\n// \u2022 generation \u2192 a long provider call vxil babysits for you, holding\n// credits while it runs and releasing them when it ends\n// \u2022 the spine \u2192 every failure is an audit event; one cron function turns\n// the ones that matter into `incidents` rows\n//\n// The credits half is a payments INTEGRATION with the deterministic `mock`\n// provider, so the whole reserve \u2192 settle \u2192 refund story runs with no provider\n// account at all. \"Credits\" here are usage units, not money.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n // a failing delivery is retried this many times before it dead-letters\n retry: { defaultMaxAttempts: 3 },\n retention: { successfulRunDays: 7, failedRunDays: 30 },\n // once this many runs have dead-lettered today, a further failing run\n // skips its remaining retries and dead-letters immediately \u2014 a bound on\n // the churn ONE pathological target can generate. 0 = unlimited.\n dlqDailyQuota: 200,\n concurrency: { maxConcurrent: 5 },\n schedules: { maxPerTenant: 20 },\n generation: {\n maxConcurrent: 5,\n defaultTimeoutMs: 300_000, // 5 min unless the descriptor says otherwise\n pollMaxAttempts: 30, // give up after 30 polls \u2192 terminal fail\n maxReserveCredits: 200, // a single run can never hold more than this\n maxOutstandingReserveCredits: 5_000, // \u2026nor can all in-flight runs together\n },\n },\n\n // The ledger half. `mock` is the deterministic default provider: no keys,\n // no account, the entire credits path exercisable end to end. Point it at\n // your own Stripe/Paddle/PayPal/RevenueCat account when you go live \u2014 vxil\n // is never in the flow of funds.\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n // product id \u2192 what buying it grants. Consumed by the provider webhook\n // reducer; the map is DATA, so adding products never changes the config.\n productMap: {\n render_pack_1000: { creditType: 'render_credits', amount: 1000, period: 'once' },\n },\n // tier \u2192 what being on it entitles you to, and what it tops up monthly\n tierMap: {\n pro: {\n entitlements: ['render'],\n quotas: { renders_per_day: 500 },\n rank: 10,\n grants: [{ creditType: 'render_credits', amount: 5_000, period: 'monthly' }],\n },\n },\n // a generation that ends in failure gives its held credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {},\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n // The work items. One row per requested render.\n renders: {\n singular: 'render',\n fields: {\n // THE DEDUPE ANCHOR. Queue deliveries are at-least-once, so the\n // worker may see the same item twice; a unique field turns the second\n // write into a clean 409 the function treats as \"already done\".\n request_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n title: { type: 'string', indexSlot: 's2' },\n state: { type: 'string', indexSlot: 's3' }, // queued | processing | done | failed\n run_id: { type: 'string', indexSlot: 's4' }, // the jobs run that owns it\n credits: { type: 'int', indexSlot: 'n1' }, // held for this render\n created_at: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n\n // The failure memory. One row per incident KEY, so ten dead letters of\n // the same job are one row \u2014 plus the reserved `__cursor__` row holding\n // the audit-stream watermark.\n incidents: {\n singular: 'incident',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n event: { type: 'string', indexSlot: 's2' }, // the audit event that raised it\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n subject: { type: 'string', indexSlot: 's4' }, // job name / schedule id\n seen_count: { type: 'int', indexSlot: 'n1' },\n first_seen: { type: 'datetime', indexSlot: 't1' },\n last_seen: { type: 'datetime', indexSlot: 't2' },\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n detail: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // THE WORKER. A queue-triggered function is invoked by an enqueue, not by a\n // request \u2014 it has no URL a browser can reach. Delivery is at-least-once,\n // so it dedupes on `request_key` rather than assuming exactly-once.\n 'process-batch': {\n entry: './functions/process-batch.ts',\n trigger: { kind: 'queue', source: 'renders' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // THE FAILURE DRAIN. Every feature writes its lifecycle to your tenant's\n // audit stream; this reads the stream past a watermark every 5 minutes and\n // turns the failure events that matter into `incidents` rows.\n 'incident-watch': {\n entry: './functions/incident-watch.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free plan: '*/15 * * * *' (its fastest function cron)\n scopes: ['cms:read', 'cms:write'],\n // The audit stream is a control-plane read, not one of the feature APIs\n // the function's scoped callback covers \u2014 so the drain reads it with the\n // narrowest key that can: one holding ONLY `features:read`.\n secrets: ['secret:vxil_read_key'],\n egressAllow: [],\n },\n },\n\n secrets: {\n vxil_read_key: {\n feature: 'functions',\n description: 'a vxil API key of this backend holding ONLY features:read \u2014 reads the audit stream',\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'renders',\n items: [\n {\n request_key: 'seed-0001',\n title: 'Quarterly report render',\n state: 'queued',\n credits: 10,\n created_at: '2026-04-01T08:00:00Z',\n notes: 'Seed row so the collection is not empty on first push.',\n },\n ],\n },\n ],\n },\n});\n",
19438
- "readme": '# Job Runner (ops)\n\nFour ways work leaves the request path, and one answer for what happens when it fails. If you have\never written a `jobs` table, a worker loop, a retry counter and a "why did this run twice?" post\nmortem, this is that, declared.\n\n```bash\nmkdir my-jobs && cd my-jobs\nvxil init --template job-runner\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\n# Free plan only: set incident-watch\'s cron to \'*/15 * * * *\' in vxil.config.ts now (note below)\nvxil login # keys mint needs a dashboard session, not a project key\nvxil keys mint --name incident-audit --scopes audit:read --json | jq -r .api_key | vxil secrets set functions/vxil_read_key\nvxil push # collections + both functions; writes vxil.types.ts\nvxil seed # the demo render row\n```\n\n> **Plan note.** The two functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n`vxil_read_key` is an API key **of this same backend** holding **only `audit:read`** \u2014 the\nincident drain reads the audit stream with it (the mint line above pipes it straight into the secret and\nneeds `jq`; or dashboard \u2192 API keys \u2192 create, tick `audit:read` and nothing else). The key is a holdover\nin the shipped function, not a platform limit: a function that declares the `audit:read` scope reads\n`/v1/audit` through its own scoped callback. `features:read` (what the config\'s comments name) also works,\nbut it also opens the whole-project export, so prefer `audit:read`. Everything else here needs no credentials at all: the payments integration runs\non the deterministic `mock` provider, so the whole credits story works before you have a provider\naccount. "Credits" are usage units you meter, not money and not stored value.\n\n> **On the Free plan** a function cron may run at most every 15 minutes \u2014 change `incident-watch` to\n> `*/15 * * * *` in `vxil.config.ts` before `vxil push`, or the deploy answers `402 plan_limit`\n> (`cron_interval`). Paid plans run the 5-minute schedule as shipped.\n\n## The four ways work leaves the request path\n\n| | You call | It runs | Use it when |\n|---|---|---|---|\n| **Enqueue** | `POST /v1/jobs/enqueue` | now, or later | one-off work, deduplicated by a key you choose |\n| **Schedule** | `POST /v1/jobs/schedules` | on a 5-field UTC cron | recurring work \u2014 and it tells you when it ran late |\n| **Queue trigger** | `vxil functions invoke <fn> --async` | a deployed function | the worker IS your code, with no URL to expose |\n| **Generation** | `POST /v1/jobs/generation` | a long provider call vxil babysits | a model/render/export that answers in minutes, not milliseconds |\n\nAnd one answer for failure: **every feature writes its lifecycle to your audit stream**, and the\n`incident-watch` cron turns the failures that matter into rows you can query.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `jobs:read jobs:write payments:read payments:write cms:read cms:write\nfunctions:read functions:invoke features:read`. The project key quickstart minted carries these (`vxil env pull && set -a && . ./.env.local && set +a`, then `KEY=$VXIL_API_KEY`); the `vxil api` lines need no variable at all.\n\n**Reading the output comments.** Each `# 200 { "data": \u2026 }` comment shows the HTTP response. A raw\n`curl` call prints exactly that; a `vxil api` line prints only what is inside `data` (no status, no\nenvelope), ready for `jq` \u2014 so `jq .field`, not `jq .data.field`.\n\n**1. Enqueue the same thing twice.** The dedupe scope is `(job_name, idempotency_key)`:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deduplicated": true } } \u2190 the SAME run_id\n```\n\nNote where the key goes: **in the body**, as `idempotency_key`. (The `Idempotency-Key` *header* is\nwhat the notifications and payments surfaces read \u2014 jobs reads the field.) The window is 24 hours,\nand it is backstopped in the database, so two racing enqueues cannot both win.\n\n**2. Run it later.** Two mutually exclusive fields \u2014 pick one:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","delay_seconds":900}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deliver_after": "\u2026T12:26:12.686Z" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","deliver_after":"2026-12-24T09:00:00Z"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "delayed", "deliver_after": "2026-12-24T09:00:00.000Z" } }\n```\n\n`state` tells you which machinery is holding it: a short delay rides the queue itself and fires on\nthe second; anything beyond twelve hours is parked as `delayed` and released by a minute-tick\nsweep. The ceiling is 30 days. A `deliver_after` in the past is a 422, not a surprise.\n\n**3. A schedule that reports itself late.**\n\n```bash\nvxil api POST /v1/jobs/schedules --data \'{"job_name":"nightly.rollup","target_url":"https://hooks.example.com/rollup","cron":"0 2 * * *"}\'\n# 201 { "data": { "schedule_id": "sch_\u2026", "next_run_at": "2026-09-12T02:00:00.000Z", "state": "active" } }\n```\n\nFive UTC fields, with `*`, numbers, lists, ranges and steps \u2014 no month or weekday names, no `L`/`W`.\nWhen a tick finally fires more than **twice its own interval** late, the platform writes\n`jobs.schedule.missed` (once per crossing, not once per tick) and `jobs.schedule.recovered` when it\ncatches up. Step 7 turns both into rows.\n\n**4. The worker is your function.** `process-batch` is queue-triggered \u2014 it has no URL a browser can\nreach. Hand it a batch:\n\n```bash\nvxil functions invoke process-batch --async --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api GET "/v1/cms/items/renders?filter=%7B%22state%22%3A%22queued%22%7D"\n# 200 \u2026 two new rows, request_key "b-1:a" and "b-1:b"\n```\n\n(`--async` enqueues it; drop the flag to call the same function synchronously and\nread its answer. `--async` needs a linked project \u2014 `vxil quickstart` or `vxil link <slug>`.)\n\nNow send the **exact same batch again**, synchronously so you can read the verdict:\n\n```bash\nvxil functions invoke process-batch --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# { "batch_id": "b-1", "created": 0, "duplicates": 2, "rejected": 0 }\n```\n\nNothing was duplicated, and the function contains no dedupe logic. `request_key` is declared\n`unique`, so the second create is a 409 \u2014 and the function reads a 409 as *already done*. That is\nthe whole strategy: **delivery is at-least-once, so correctness lives in a declared field, not in a\nhope that it runs once.**\n\n**5. Credits: reserve \u2192 settle.** Grant some usage units first (the header is required here \u2014 this\nis the money-shaped surface):\n\n```bash\ncurl -s -X POST "https://api.vxil.com/v1/payments/credits/grant" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -H \'idempotency-key: seed-1\' \\\n -d \'{"user_id":"u_demo","credit_type":"render_credits","amount":100,"source":"seed"}\'\n# 200 { "data": { "balance_after": 100, "ledger_entry_id": null } }\n\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "credit_type": "render_credits", "balance": 100, "held": 0, "available": 100, \u2026 } }\n```\n\nNow start a generation that holds ten of them while it runs:\n\n```bash\nvxil api POST /v1/jobs/generation --data \'{\n "job_name": "render.deck",\n "provider": { "url": "https://api.your-render-provider.example/v1/renders",\n "method": "POST",\n "body": { "format": "pdf" } },\n "completion": { "mode": "poll",\n "status_path": "status",\n "poll": { "url": "https://api.your-render-provider.example/v1/renders/latest",\n "method": "GET", "interval_ms": 5000 } },\n "reserve_credits": { "amount": 10, "user_id": "u_demo",\n "credit_type": "render_credits", "reason": "deck render" },\n "timeout": { "after_ms": 120000 },\n "payload": { "render_id": "b-1:a" }\n}\'\n# 202 { "data": { "run_id": "run_\u2026", "generation_status": "pending", "state": "queued" } }\n```\n\nImmediately, the balance moves \u2014 but only the **held** half:\n\n```bash\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "balance": 100, "held": 10, "available": 90, \u2026 } }\n\nvxil api GET "/v1/payments/usage?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "user_id": "u_demo", "entries": [\n# { "kind": "consume", "state": "provisional", "delta": -10, "job_id": "run_\u2026", "source": "deck render", \u2026 },\n# { "kind": "grant", "state": "committed", "delta": 100, \u2026 } ] } }\n```\n\n`state: "provisional"` is the reservation. Nothing has been spent yet \u2014 `balance` is untouched and\n`available` dropped, so the same user cannot start ten more renders on credits they do not have.\n\nWhen the run reaches a terminal state, vxil settles it for you, and the ledger says which way it\nwent:\n\n- **completed** \u2192 the provisional row flips to `committed` and a `settle` row lands with\n `source: "job:succeeded"`. The credits were spent.\n- **failed / timed out** \u2192 a `reversal` row lands with a **positive** delta and\n `source: "job:failed"`, the original flips to `reversed`, and `held` returns to zero. The\n customer was not charged for work that did not happen. That refund is the\n `ledger.autoRefundOnJobFailure` flag in `vxil.config.ts`; turn it off and a failure still releases\n the hold but keeps the charge.\n\nTwo ceilings you do not have to remember to set: a single run\'s request is **clamped** to\n`generation.maxReserveCredits` (never rejected, so a bad caller cannot break the flow), and the sum\nof all outstanding holds is refused past `generation.maxOutstandingReserveCredits` with a 429. Both\nhave safe defaults.\n\nPoll mode is exactly what it says: vxil calls your provider once, then re-reads\n`completion.poll.url` every `interval_ms`, reading `status_path` out of the body. Anything it does\nnot recognise counts as *still processing* \u2014 a generation is never silently completed by a typo.\nAfter `pollMaxAttempts` it fails terminally, and the timeout does the same on the wall clock.\n\n**6. Dead letters and replay.** There is no separate dead-letter inbox \u2014 a dead letter is a run in\nthe `dead` state:\n\n```bash\nvxil api GET "/v1/jobs/runs?state=dead&limit=20"\n# 200 { "data": { "runs": [ { "run_id": "run_\u2026", "job_name": "report.email", "state": "dead",\n# "attempt_number": 1, "max_attempts": 1,\n# "last_error_class": "NonRetryableHttp",\n# "last_error_msg": "target returned 404", \u2026 } ] } }\n\nvxil api POST /v1/jobs/runs/run_\u2026/replay\n# 202 { "data": { "run_id": "run_NEW", "replayed_from": "run_\u2026", "state": "queued" } }\n```\n\nReplay takes **no body** and clones the original into a *new* run \u2014 the dead row is evidence and\nstays untouched. Only terminal runs replay; anything still in flight is a `409 not_replayable`.\n\n**7. Failures become rows.** `incident-watch` runs every five minutes (every fifteen on the Free plan). Force it once:\n\n```bash\nvxil functions invoke incident-watch\n# first run: { "initialized": true, "cursor": "\u2026", "raised": 0 } \u2190 history never floods you\n# after a dead letter, the next run:\n# { "scanned": 12, "raised": 1, "resolved": 0, "cursor": "\u2026" }\n\nvxil api GET /v1/cms/items/incidents\n# 200 \u2026 { "key": "jobs:dead-letter:report.email", "event": "job.dead_lettered", "level": "error",\n# "subject": "report.email", "seen_count": 1, "first_seen": "\u2026", "last_seen": "\u2026",\n# "detail": "run run_\u2026 died after attempt 1" }\n```\n\nTen dead letters of the same job produce **one** row with `seen_count: 10`, because the incident\nkey collapses them. `jobs.schedule.missed` and `jobs.schedule.recovered` share a key, so a schedule\nthat catches up closes its own incident. The `RULES` table in `functions/incident-watch.ts` is the\nallow-list \u2014 plain data. Widen it from the catalog of everything the platform can emit.\n\nThis blueprint does not enable `webhooks`, so the project key has no webhooks scopes and the two\ncalls below would get `403 insufficient_scope` through `vxil api`. Mint a key that has them (after\n`vxil login`) and call with it:\n\n```bash\nHOOKS_KEY=$(vxil keys mint --name ops-hooks --scopes webhooks:read,webhooks:write --json | jq -r .api_key)\ncurl -s https://api.vxil.com/v1/webhooks/events/catalog -H "authorization: Bearer $HOOKS_KEY"\n# { "count": \u2026, "events": [ { "name": "job.dead_lettered", "feature": "jobs",\n# "level": "failure", "payload_keys": [ \u2026 ] } \u2026 ],\n# "prefixes": [ { "prefix": "payments.", "count": \u2026 }, \u2026 ] } \u2190 one entry per event prefix\n```\n\nIf you would rather the same events went to **your own** endpoint than into a collection, subscribe\nto the spine directly \u2014 same events, different consumer:\n\n```bash\ncurl -s -X POST https://api.vxil.com/v1/webhooks/subscriptions \\\n -H "authorization: Bearer $HOOKS_KEY" -H \'content-type: application/json\' \\\n -d \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["job.","jobs."]}\'\n```\n\n## What to learn from this\n\n- **Exactly-once is not a delivery guarantee you can buy; it is a field you declare.** `unique` on\n `request_key`, `idempotency_key` on the enqueue, `foreign_id` elsewhere \u2014 each converts\n at-least-once delivery into an at-most-once *effect*.\n- **A reservation is not a charge.** Holding credits while long work runs, and releasing them if it\n fails, is the difference between metering and billing people for your outages. The ledger shows\n both halves, so you can answer "why was I charged?" from a query.\n- **Failure needs a vocabulary, not a log.** `job.dead_lettered`, `jobs.schedule.missed`,\n `job.generation.failed` are named events with stable payloads \u2014 that is why a 40-line function can\n turn them into an incident board, and why the catalog route can tell an agent what exists.\n- **Late is a different failure from broken.** A schedule that fires twice its interval late says so\n once, and says so again when it recovers. Alerting on every tick teaches people to mute you.\n- **The clamp beats the rejection.** Capping a requested hold, rather than refusing it, keeps a\n careless caller from breaking the flow while still bounding the blast radius.\n\n**Pairs with:** the **alerts-to-slack** blueprint (the same drain, routed to a chat channel instead of a\ncollection) and the **payments-heartbeat** blueprint (noticing the failure that is *silence*).\n',
20047
+ "byoKeys": [],
20048
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Job Runner\" \u2014 the BACKGROUND-WORK blueprint. Four ways work leaves the\n// request path, and what happens when it fails:\n//\n// \u2022 enqueue \u2192 one-off work, deduplicated by an idempotency key, and\n// optionally deferred (seconds from now, or an exact time)\n// \u2022 schedules \u2192 cron, with a LATE schedule reporting itself\n// \u2022 queue trigger \u2192 a deployed function that IS the worker\n// \u2022 generation \u2192 a long provider call vxil babysits for you, holding\n// credits while it runs and releasing them when it ends\n// \u2022 the spine \u2192 every failure is an audit event; one cron function turns\n// the ones that matter into `incidents` rows\n//\n// The credits half is a payments INTEGRATION with the deterministic `mock`\n// provider, so the whole reserve \u2192 settle \u2192 refund story runs with no provider\n// account at all. \"Credits\" here are usage units, not money.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n // a failing delivery is retried this many times before it dead-letters\n retry: { defaultMaxAttempts: 3 },\n retention: { successfulRunDays: 7, failedRunDays: 30 },\n // once this many runs have dead-lettered today, a further failing run\n // skips its remaining retries and dead-letters immediately \u2014 a bound on\n // the churn ONE pathological target can generate. 0 = unlimited.\n dlqDailyQuota: 200,\n concurrency: { maxConcurrent: 5 },\n schedules: { maxPerTenant: 20 },\n generation: {\n maxConcurrent: 5,\n defaultTimeoutMs: 300_000, // 5 min unless the descriptor says otherwise\n pollMaxAttempts: 30, // give up after 30 polls \u2192 terminal fail\n maxReserveCredits: 200, // a single run can never hold more than this\n maxOutstandingReserveCredits: 5_000, // \u2026nor can all in-flight runs together\n },\n },\n\n // The ledger half. `mock` is the deterministic default provider: no keys,\n // no account, the entire credits path exercisable end to end. Point it at\n // your own Stripe/Paddle/PayPal/RevenueCat account when you go live \u2014 vxil\n // is never in the flow of funds.\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n // product id \u2192 what buying it grants. Consumed by the provider webhook\n // reducer; the map is DATA, so adding products never changes the config.\n productMap: {\n render_pack_1000: { creditType: 'render_credits', amount: 1000, period: 'once' },\n },\n // tier \u2192 what being on it entitles you to, and what it tops up monthly\n tierMap: {\n pro: {\n entitlements: ['render'],\n quotas: { renders_per_day: 500 },\n rank: 10,\n grants: [{ creditType: 'render_credits', amount: 5_000, period: 'monthly' }],\n },\n },\n // a generation that ends in failure gives its held credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {},\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n // The work items. One row per requested render.\n renders: {\n singular: 'render',\n fields: {\n // THE DEDUPE ANCHOR. Queue deliveries are at-least-once, so the\n // worker may see the same item twice; a unique field turns the second\n // write into a clean 409 the function treats as \"already done\".\n request_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n title: { type: 'string', indexSlot: 's2' },\n state: { type: 'string', indexSlot: 's3' }, // queued | processing | done | failed\n run_id: { type: 'string', indexSlot: 's4' }, // the jobs run that owns it\n credits: { type: 'int', indexSlot: 'n1' }, // held for this render\n created_at: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n\n // The failure memory. One row per incident KEY, so ten dead letters of\n // the same job are one row \u2014 plus the reserved `__cursor__` row holding\n // the audit-stream watermark.\n incidents: {\n singular: 'incident',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n event: { type: 'string', indexSlot: 's2' }, // the audit event that raised it\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n subject: { type: 'string', indexSlot: 's4' }, // job name / schedule id\n seen_count: { type: 'int', indexSlot: 'n1' },\n first_seen: { type: 'datetime', indexSlot: 't1' },\n last_seen: { type: 'datetime', indexSlot: 't2' },\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n detail: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // THE WORKER. A queue-triggered function is invoked by an enqueue, not by a\n // request \u2014 it has no URL a browser can reach. Delivery is at-least-once,\n // so it dedupes on `request_key` rather than assuming exactly-once.\n 'process-batch': {\n entry: './functions/process-batch.ts',\n trigger: { kind: 'queue', source: 'renders' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // THE FAILURE DRAIN. Every feature writes its lifecycle to your tenant's\n // audit stream; this reads the stream past a watermark every 5 minutes and\n // turns the failure events that matter into `incidents` rows.\n 'incident-watch': {\n entry: './functions/incident-watch.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free plan: '*/15 * * * *' (its fastest function cron)\n // `audit:read` mints the function's confined control-plane token\n // (env.scoped_jwts['control-plane']), which reads the audit stream and\n // nothing else \u2014 no API key stored as a secret.\n scopes: ['cms:read', 'cms:write', 'audit:read'],\n egressAllow: [],\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'renders',\n items: [\n {\n request_key: 'seed-0001',\n title: 'Quarterly report render',\n state: 'queued',\n credits: 10,\n created_at: '2026-04-01T08:00:00Z',\n notes: 'Seed row so the collection is not empty on first push.',\n },\n ],\n },\n ],\n },\n});\n",
20049
+ "readme": '# Job Runner (ops)\n\nFour ways work leaves the request path, and one answer for what happens when it fails. If you have\never written a `jobs` table, a worker loop, a retry counter and a "why did this run twice?" post\nmortem, this is that, declared.\n\n```bash\nmkdir my-jobs && cd my-jobs\nvxil init --template job-runner\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\n# Free plan only: set incident-watch\'s cron to \'*/15 * * * *\' in vxil.config.ts now (note below)\nvxil push # collections + both functions; writes vxil.types.ts\nvxil seed # the demo render row\n```\n\n> **Plan note.** The two functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\nThe incident drain needs no API key: `incident-watch` declares the `audit:read` scope, so its scoped\ncallback carries a confined token that reads the audit stream (`/v1/audit`, `/v1/audit/export`) and\nnothing else. Nothing else needs credentials either: the payments integration runs\non the deterministic `mock` provider, so the whole credits story works before you have a provider\naccount. "Credits" are usage units you meter, not money and not stored value.\n\n> **On the Free plan** a function cron may run at most every 15 minutes \u2014 change `incident-watch` to\n> `*/15 * * * *` in `vxil.config.ts` before `vxil push`, or the deploy answers `402 plan_limit`\n> (`cron_interval`). Paid plans run the 5-minute schedule as shipped.\n\n## The four ways work leaves the request path\n\n| | You call | It runs | Use it when |\n|---|---|---|---|\n| **Enqueue** | `POST /v1/jobs/enqueue` | now, or later | one-off work, deduplicated by a key you choose |\n| **Schedule** | `POST /v1/jobs/schedules` | on a 5-field UTC cron | recurring work \u2014 and it tells you when it ran late |\n| **Queue trigger** | `vxil functions invoke <fn> --async` | a deployed function | the worker IS your code, with no URL to expose |\n| **Generation** | `POST /v1/jobs/generation` | a long provider call vxil babysits | a model/render/export that answers in minutes, not milliseconds |\n\nAnd one answer for failure: **every feature writes its lifecycle to your audit stream**, and the\n`incident-watch` cron turns the failures that matter into rows you can query.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `jobs:read jobs:write payments:read payments:write cms:read cms:write\nfunctions:read functions:invoke features:read`. The project key quickstart minted carries these (`vxil env pull && set -a && . ./.env.local && set +a`, then `KEY=$VXIL_API_KEY`); the `vxil api` lines need no variable at all.\n\n**Reading the output comments.** Each `# 200 { "data": \u2026 }` comment shows the HTTP response. A raw\n`curl` call prints exactly that; a `vxil api` line prints only what is inside `data` (no status, no\nenvelope), ready for `jq` \u2014 so `jq .field`, not `jq .data.field`.\n\n**1. Enqueue the same thing twice.** The dedupe scope is `(job_name, idempotency_key)`:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deduplicated": true } } \u2190 the SAME run_id\n```\n\nNote where the key goes: **in the body**, as `idempotency_key`. (The `Idempotency-Key` *header* is\nwhat the notifications and payments surfaces read \u2014 jobs reads the field.) The window is 24 hours,\nand it is backstopped in the database, so two racing enqueues cannot both win.\n\n**2. Run it later.** Two mutually exclusive fields \u2014 pick one:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","delay_seconds":900}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deliver_after": "\u2026T12:26:12.686Z" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","deliver_after":"2026-12-24T09:00:00Z"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "delayed", "deliver_after": "2026-12-24T09:00:00.000Z" } }\n```\n\n`state` tells you which machinery is holding it: a short delay rides the queue itself and fires on\nthe second; anything beyond twelve hours is parked as `delayed` and released by a minute-tick\nsweep. The ceiling is 30 days. A `deliver_after` in the past is a 422, not a surprise.\n\n**3. A schedule that reports itself late.**\n\n```bash\nvxil api POST /v1/jobs/schedules --data \'{"job_name":"nightly.rollup","target_url":"https://hooks.example.com/rollup","cron":"0 2 * * *"}\'\n# 201 { "data": { "schedule_id": "sch_\u2026", "next_run_at": "2026-09-12T02:00:00.000Z", "state": "active" } }\n```\n\nFive UTC fields, with `*`, numbers, lists, ranges and steps \u2014 no month or weekday names, no `L`/`W`.\nWhen a tick finally fires more than **twice its own interval** late, the platform writes\n`jobs.schedule.missed` (once per crossing, not once per tick) and `jobs.schedule.recovered` when it\ncatches up. Step 7 turns both into rows.\n\n**4. The worker is your function.** `process-batch` is queue-triggered \u2014 it has no URL a browser can\nreach. Hand it a batch:\n\n```bash\nvxil functions invoke process-batch --async --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api GET "/v1/cms/items/renders?filter=%7B%22state%22%3A%22queued%22%7D"\n# 200 \u2026 two new rows, request_key "b-1:a" and "b-1:b"\n```\n\n(`--async` enqueues it; drop the flag to call the same function synchronously and\nread its answer. `--async` needs a linked project \u2014 `vxil quickstart` or `vxil link <slug>`.)\n\nNow send the **exact same batch again**, synchronously so you can read the verdict:\n\n```bash\nvxil functions invoke process-batch --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# { "batch_id": "b-1", "created": 0, "duplicates": 2, "rejected": 0 }\n```\n\nNothing was duplicated, and the function contains no dedupe logic. `request_key` is declared\n`unique`, so the second create is a 409 \u2014 and the function reads a 409 as *already done*. That is\nthe whole strategy: **delivery is at-least-once, so correctness lives in a declared field, not in a\nhope that it runs once.**\n\n**5. Credits: reserve \u2192 settle.** Grant some usage units first (the header is required here \u2014 this\nis the money-shaped surface):\n\n```bash\ncurl -s -X POST "https://api.vxil.com/v1/payments/credits/grant" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -H \'idempotency-key: seed-1\' \\\n -d \'{"user_id":"u_demo","credit_type":"render_credits","amount":100,"source":"seed"}\'\n# 200 { "data": { "balance_after": 100, "ledger_entry_id": null } }\n\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "credit_type": "render_credits", "balance": 100, "held": 0, "available": 100, \u2026 } }\n```\n\nNow start a generation that holds ten of them while it runs:\n\n```bash\nvxil api POST /v1/jobs/generation --data \'{\n "job_name": "render.deck",\n "provider": { "url": "https://api.your-render-provider.example/v1/renders",\n "method": "POST",\n "body": { "format": "pdf" } },\n "completion": { "mode": "poll",\n "status_path": "status",\n "poll": { "url": "https://api.your-render-provider.example/v1/renders/latest",\n "method": "GET", "interval_ms": 5000 } },\n "reserve_credits": { "amount": 10, "user_id": "u_demo",\n "credit_type": "render_credits", "reason": "deck render" },\n "timeout": { "after_ms": 120000 },\n "payload": { "render_id": "b-1:a" }\n}\'\n# 202 { "data": { "run_id": "run_\u2026", "generation_status": "pending", "state": "queued" } }\n```\n\nImmediately, the balance moves \u2014 but only the **held** half:\n\n```bash\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "balance": 100, "held": 10, "available": 90, \u2026 } }\n\nvxil api GET "/v1/payments/usage?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "user_id": "u_demo", "entries": [\n# { "kind": "consume", "state": "provisional", "delta": -10, "job_id": "run_\u2026", "source": "deck render", \u2026 },\n# { "kind": "grant", "state": "committed", "delta": 100, \u2026 } ] } }\n```\n\n`state: "provisional"` is the reservation. Nothing has been spent yet \u2014 `balance` is untouched and\n`available` dropped, so the same user cannot start ten more renders on credits they do not have.\n\nWhen the run reaches a terminal state, vxil settles it for you, and the ledger says which way it\nwent:\n\n- **completed** \u2192 the provisional row flips to `committed` and a `settle` row lands with\n `source: "job:succeeded"`. The credits were spent.\n- **failed / timed out** \u2192 a `reversal` row lands with a **positive** delta and\n `source: "job:failed"`, the original flips to `reversed`, and `held` returns to zero. The\n customer was not charged for work that did not happen. That refund is the\n `ledger.autoRefundOnJobFailure` flag in `vxil.config.ts`; turn it off and a failure still releases\n the hold but keeps the charge.\n\nTwo ceilings you do not have to remember to set: a single run\'s request is **clamped** to\n`generation.maxReserveCredits` (never rejected, so a bad caller cannot break the flow), and the sum\nof all outstanding holds is refused past `generation.maxOutstandingReserveCredits` with a 429. Both\nhave safe defaults.\n\nPoll mode is exactly what it says: vxil calls your provider once, then re-reads\n`completion.poll.url` every `interval_ms`, reading `status_path` out of the body. Anything it does\nnot recognise counts as *still processing* \u2014 a generation is never silently completed by a typo.\nAfter `pollMaxAttempts` it fails terminally, and the timeout does the same on the wall clock.\n\n**6. Dead letters and replay.** There is no separate dead-letter inbox \u2014 a dead letter is a run in\nthe `dead` state:\n\n```bash\nvxil api GET "/v1/jobs/runs?state=dead&limit=20"\n# 200 { "data": { "runs": [ { "run_id": "run_\u2026", "job_name": "report.email", "state": "dead",\n# "attempt_number": 1, "max_attempts": 1,\n# "last_error_class": "NonRetryableHttp",\n# "last_error_msg": "target returned 404", \u2026 } ] } }\n\nvxil api POST /v1/jobs/runs/run_\u2026/replay\n# 202 { "data": { "run_id": "run_NEW", "replayed_from": "run_\u2026", "state": "queued" } }\n```\n\nReplay takes **no body** and clones the original into a *new* run \u2014 the dead row is evidence and\nstays untouched. Only terminal runs replay; anything still in flight is a `409 not_replayable`.\n\n**7. Failures become rows.** `incident-watch` runs every five minutes (every fifteen on the Free plan). Force it once:\n\n```bash\nvxil functions invoke incident-watch\n# first run: { "initialized": true, "cursor": "\u2026", "raised": 0 } \u2190 history never floods you\n# after a dead letter, the next run:\n# { "scanned": 12, "raised": 1, "resolved": 0, "cursor": "\u2026" }\n\nvxil api GET /v1/cms/items/incidents\n# 200 \u2026 { "key": "jobs:dead-letter:report.email", "event": "job.dead_lettered", "level": "error",\n# "subject": "report.email", "seen_count": 1, "first_seen": "\u2026", "last_seen": "\u2026",\n# "detail": "run run_\u2026 died after attempt 1" }\n```\n\nTen dead letters of the same job produce **one** row with `seen_count: 10`, because the incident\nkey collapses them. `jobs.schedule.missed` and `jobs.schedule.recovered` share a key, so a schedule\nthat catches up closes its own incident. The `RULES` table in `functions/incident-watch.ts` is the\nallow-list \u2014 plain data. Widen it from the catalog of everything the platform can emit.\n\nThis blueprint does not enable `webhooks`, so the project key has no webhooks scopes and the two\ncalls below would get `403 insufficient_scope` through `vxil api`. Mint a key that has them (after\n`vxil login`) and call with it:\n\n```bash\nHOOKS_KEY=$(vxil keys mint --name ops-hooks --scopes webhooks:read,webhooks:write --json | jq -r .api_key)\ncurl -s https://api.vxil.com/v1/webhooks/events/catalog -H "authorization: Bearer $HOOKS_KEY"\n# { "count": \u2026, "events": [ { "name": "job.dead_lettered", "feature": "jobs",\n# "level": "failure", "payload_keys": [ \u2026 ] } \u2026 ],\n# "prefixes": [ { "prefix": "payments.", "count": \u2026 }, \u2026 ] } \u2190 one entry per event prefix\n```\n\nIf you would rather the same events went to **your own** endpoint than into a collection, subscribe\nto the spine directly \u2014 same events, different consumer:\n\n```bash\ncurl -s -X POST https://api.vxil.com/v1/webhooks/subscriptions \\\n -H "authorization: Bearer $HOOKS_KEY" -H \'content-type: application/json\' \\\n -d \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["job.","jobs."]}\'\n```\n\n## What to learn from this\n\n- **Exactly-once is not a delivery guarantee you can buy; it is a field you declare.** `unique` on\n `request_key`, `idempotency_key` on the enqueue, `foreign_id` elsewhere \u2014 each converts\n at-least-once delivery into an at-most-once *effect*.\n- **A reservation is not a charge.** Holding credits while long work runs, and releasing them if it\n fails, is the difference between metering and billing people for your outages. The ledger shows\n both halves, so you can answer "why was I charged?" from a query.\n- **Failure needs a vocabulary, not a log.** `job.dead_lettered`, `jobs.schedule.missed`,\n `job.generation.failed` are named events with stable payloads \u2014 that is why a 40-line function can\n turn them into an incident board, and why the catalog route can tell an agent what exists.\n- **Late is a different failure from broken.** A schedule that fires twice its interval late says so\n once, and says so again when it recovers. Alerting on every tick teaches people to mute you.\n- **The clamp beats the rejection.** Capping a requested hold, rather than refusing it, keeps a\n careless caller from breaking the flow while still bounding the blast radius.\n\n**Pairs with:** the **alerts-to-slack** blueprint (the same drain, routed to a chat channel instead of a\ncollection) and the **payments-heartbeat** blueprint (noticing the failure that is *silence*).\n',
19439
20050
  "functions": {
19440
- "incident-watch.ts": "// incident-watch.ts \u2014 FAILURE EVENTS \u2192 `incidents` ROWS (a vxil function).\n//\n// Trigger: cron `*/5 * * * *`. Every feature writes its lifecycle to your\n// tenant's AUDIT STREAM \u2014 that stream is the event spine, and this function is\n// one consumer of it. Each run:\n// 1. reads the watermark (the reserved `__cursor__` row in `incidents`; the\n// first run stores the NEWEST id and stops, so history never floods you),\n// 2. drains `GET /v1/audit/export?after_id=\u2026` (ascending NDJSON) past it,\n// 3. matches each row against RULES \u2014 an allow-list, not a firehose,\n// 4. upserts ONE row per incident key (ten dead letters of the same job are\n// one row with `seen_count: 10`, not ten rows),\n// 5. advances the watermark with If-Match, so an overlapping tick stands down\n// instead of double-counting.\n//\n// WHY A KEY: the audit stream is a platform read, not one of the feature APIs\n// the function's scoped callback covers \u2014 so the drain uses the narrowest key\n// that can reach it, one holding ONLY `features:read`, stored as a secret and\n// revocable without a redeploy.\n//\n// To fan the same events out to YOUR OWN https endpoint instead, subscribe:\n// POST /v1/webhooks/subscriptions { \"target_url\": \"\u2026\", \"event_prefixes\": [\"job.\"] }\n// Both ride the same spine; this one keeps the state inside your backend.\n\n// cron-walk: persisted-cursor \u2014 the audit watermark lives in the `__cursor__` row and advances with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_PAGES_PER_RUN = 5; // \xD7 500 rows \u2014 bounds one tick\nconst DETAIL_MAX = 240;\n\ntype Level = 'info' | 'warn' | 'error';\ntype Payload = Record<string, unknown>;\ninterface Rule {\n level: Level;\n key: (p: Payload) => string;\n subject: (p: Payload) => string;\n detail: (p: Payload) => string;\n /** a healing event: clears the row rather than incrementing it */\n resolves?: boolean;\n}\n\nconst str = (v: unknown, fallback = 'unknown') => (typeof v === 'string' && v ? v : fallback);\nconst num = (v: unknown) => (typeof v === 'number' ? v : null);\n\n/** THE ALLOW-LIST. Every name below is an audit event the jobs feature writes\n * today; anything not listed is ignored. Own this table \u2014 it is data, not a\n * routing engine. `GET /v1/webhooks/events/catalog` lists every event the\n * platform can emit if you want to widen it. */\nconst RULES: Record<string, Rule> = {\n // a run exhausted its retries\n 'job.dead_lettered': {\n level: 'error',\n key: (p) => `jobs:dead-letter:${str(p.job_name)}`,\n subject: (p) => str(p.job_name),\n detail: (p) => `run ${str(p.run_id)} died after attempt ${num(p.attempt) ?? '?'}`,\n },\n // the tenant burned its daily dead-letter budget \u2014 a louder, rarer signal\n 'job.dead_letter_quota_exceeded': {\n level: 'error',\n key: () => 'jobs:dead-letter-quota',\n subject: (p) => str(p.job_name),\n detail: (p) => `daily dead-letter quota spent; run ${str(p.run_id)} skipped its retries`,\n },\n // a schedule fired more than 2\xD7 its own interval late\n 'jobs.schedule.missed': {\n level: 'warn',\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n subject: (p) => str(p.job_name),\n detail: (p) =>\n `expected ${str(p.expected_at)}, observed ${str(p.observed_at)} `\n + `(${num(p.late_seconds) ?? '?'}s late on a ${num(p.interval_seconds) ?? '?'}s interval)`,\n },\n // \u2026and its healing twin, sharing the SAME key, so recovery closes the row\n 'jobs.schedule.recovered': {\n level: 'info',\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n subject: (p) => str(p.job_name),\n detail: (p) => `back on time at ${str(p.observed_at)}`,\n resolves: true,\n },\n // a generation run ended in a terminal failure (its held credits are released)\n 'job.generation.failed': {\n level: 'error',\n key: (p) => `jobs:generation:${str(p.error_class, 'unclassified')}`,\n subject: (p) => str(p.error_class, 'unclassified'),\n detail: (p) => `run ${str(p.run_id)} failed (${str(p.error_class, 'no error class')})`,\n },\n};\n\ninterface AuditRow { id?: string | number; event?: string; created_at?: string; payload?: Payload }\ninterface Item { item_id: string; version: number; data: Record<string, unknown> }\n// the schedule tick, or a hand invoke (`POST /v1/fn/incident-watch`) carrying `{ dry_run }`\ntype Env = CronFunctionEnvelope | HttpFunctionEnvelope<{ dry_run?: boolean }>;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const readKey = env.secrets?.vxil_read_key;\n if (!cms || !readKey) {\n return Response.json({ error: 'missing cms scope or vxil_read_key secret' }, { status: 503 });\n }\n const store = new Store(base, cms);\n\n // 1. the watermark\n const cursorRow = await store.byKey('__cursor__');\n if (!cursorRow) {\n const newest = await newestAuditId(base, readKey);\n await store.create({ key: '__cursor__', cursor: newest, last_seen: new Date().toISOString() });\n return Response.json({ initialized: true, cursor: newest, raised: 0 });\n }\n let cursor = String(cursorRow.data.cursor ?? '0');\n\n // 2. drain\n const rows: AuditRow[] = [];\n for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {\n const { rows: batch, next } = await drain(base, readKey, cursor);\n rows.push(...batch);\n if (batch.length > 0) cursor = String(batch[batch.length - 1]!.id ?? cursor);\n if (!next || batch.length === 0) break;\n cursor = next;\n }\n\n // 3 + 4. match and upsert\n let raised = 0;\n let resolved = 0;\n const now = new Date().toISOString();\n for (const row of rows) {\n const rule = RULES[String(row.event ?? '')];\n if (!rule) continue;\n const p = row.payload ?? {};\n const key = rule.key(p);\n const existing = await store.byKey(key);\n\n if (rule.resolves) {\n if (existing) {\n await store.patch(existing, {\n level: 'info', event: String(row.event), detail: rule.detail(p).slice(0, DETAIL_MAX),\n last_seen: now,\n });\n resolved++;\n }\n continue;\n }\n if (existing) {\n await store.patch(existing, {\n event: String(row.event),\n level: rule.level,\n subject: rule.subject(p),\n seen_count: Number(existing.data.seen_count ?? 0) + 1,\n detail: rule.detail(p).slice(0, DETAIL_MAX),\n last_seen: now,\n });\n } else {\n await store.create({\n key,\n event: String(row.event),\n level: rule.level,\n subject: rule.subject(p),\n seen_count: 1,\n first_seen: now,\n last_seen: now,\n detail: rule.detail(p).slice(0, DETAIL_MAX),\n });\n }\n raised++;\n }\n\n // 5. advance \u2014 If-Match, so exactly one overlapping run wins\n if (!env.payload?.dry_run) {\n await store.patch(cursorRow, { cursor, last_seen: now });\n }\n return Response.json({ scanned: rows.length, raised, resolved, cursor });\n },\n};\n\n// \u2500\u2500 the audit stream (ascending NDJSON; `x-vxil-next-after-id` continues it) \u2500\u2500\nasync function drain(\n base: string, key: string, afterId: string,\n): Promise<{ rows: AuditRow[]; next: string | null }> {\n const res = await fetch(\n `${base}/v1/audit/export?after_id=${encodeURIComponent(afterId)}&limit=500`,\n { headers: { authorization: `Bearer ${key}` } },\n );\n if (!res.ok) throw new Error(`audit export ${res.status}`);\n const rows = (await res.text()).split('\\n').filter(Boolean).map((l) => JSON.parse(l) as AuditRow);\n return { rows, next: res.headers.get('x-vxil-next-after-id') };\n}\nasync function newestAuditId(base: string, key: string): Promise<string> {\n const res = await fetch(`${base}/v1/audit?limit=1`, { headers: { authorization: `Bearer ${key}` } });\n if (!res.ok) throw new Error(`audit list ${res.status}`);\n const body = (await res.json()) as { data?: { events?: AuditRow[] } };\n return String(body.data?.events?.[0]?.id ?? '0');\n}\n\n// \u2500\u2500 the cms store (the REST envelope is { data: { items: [{ item_id, version, data }] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() {\n return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' };\n }\n async byKey(key: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${this.base}/v1/cms/items/incidents?filter=${filter}&limit=1`, {\n headers: this.h(),\n });\n if (!res.ok) return null;\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 \u2014 a concurrent run already claimed this unique key */\n async create(data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/incidents`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n /** false on 409 \u2014 a concurrent run already moved this row past `version` */\n async patch(item: Item, data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/incidents/${item.item_id}`, {\n method: 'PATCH',\n headers: { ...this.h(), 'if-match': String(item.version) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n",
20051
+ "incident-watch.ts": "// incident-watch.ts \u2014 FAILURE EVENTS \u2192 `incidents` ROWS (a vxil function).\n//\n// Trigger: cron `*/5 * * * *`. Every feature writes its lifecycle to your\n// tenant's AUDIT STREAM \u2014 that stream is the event spine, and this function is\n// one consumer of it. Each run:\n// 1. reads the watermark (the reserved `__cursor__` row in `incidents`; the\n// first run stores the NEWEST id and stops, so history never floods you),\n// 2. drains `GET /v1/audit/export?after_id=\u2026` (ascending NDJSON) past it,\n// 3. matches each row against RULES \u2014 an allow-list, not a firehose,\n// 4. upserts ONE row per incident key (ten dead letters of the same job are\n// one row with `seen_count: 10`, not ten rows),\n// 5. advances the watermark with If-Match, so an overlapping tick stands down\n// instead of double-counting.\n//\n// NO KEY: the function declares `audit:read`, so its scoped callback carries a\n// confined control-plane token (env.scoped_jwts['control-plane']) that reads\n// the audit stream and nothing else.\n//\n// To fan the same events out to YOUR OWN https endpoint instead, subscribe:\n// POST /v1/webhooks/subscriptions { \"target_url\": \"\u2026\", \"event_prefixes\": [\"job.\"] }\n// Both ride the same spine; this one keeps the state inside your backend.\n\n// cron-walk: persisted-cursor \u2014 the audit watermark lives in the `__cursor__` row and advances with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_PAGES_PER_RUN = 5; // \xD7 500 rows \u2014 bounds one tick\nconst DETAIL_MAX = 240;\n\ntype Level = 'info' | 'warn' | 'error';\ntype Payload = Record<string, unknown>;\ninterface Rule {\n level: Level;\n key: (p: Payload) => string;\n subject: (p: Payload) => string;\n detail: (p: Payload) => string;\n /** a healing event: clears the row rather than incrementing it */\n resolves?: boolean;\n}\n\nconst str = (v: unknown, fallback = 'unknown') => (typeof v === 'string' && v ? v : fallback);\nconst num = (v: unknown) => (typeof v === 'number' ? v : null);\n\n/** THE ALLOW-LIST. Every name below is an audit event the jobs feature writes\n * today; anything not listed is ignored. Own this table \u2014 it is data, not a\n * routing engine. `GET /v1/webhooks/events/catalog` lists every event the\n * platform can emit if you want to widen it. */\nconst RULES: Record<string, Rule> = {\n // a run exhausted its retries\n 'job.dead_lettered': {\n level: 'error',\n key: (p) => `jobs:dead-letter:${str(p.job_name)}`,\n subject: (p) => str(p.job_name),\n detail: (p) => `run ${str(p.run_id)} died after attempt ${num(p.attempt) ?? '?'}`,\n },\n // the tenant burned its daily dead-letter budget \u2014 a louder, rarer signal\n 'job.dead_letter_quota_exceeded': {\n level: 'error',\n key: () => 'jobs:dead-letter-quota',\n subject: (p) => str(p.job_name),\n detail: (p) => `daily dead-letter quota spent; run ${str(p.run_id)} skipped its retries`,\n },\n // a schedule fired more than 2\xD7 its own interval late\n 'jobs.schedule.missed': {\n level: 'warn',\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n subject: (p) => str(p.job_name),\n detail: (p) =>\n `expected ${str(p.expected_at)}, observed ${str(p.observed_at)} `\n + `(${num(p.late_seconds) ?? '?'}s late on a ${num(p.interval_seconds) ?? '?'}s interval)`,\n },\n // \u2026and its healing twin, sharing the SAME key, so recovery closes the row\n 'jobs.schedule.recovered': {\n level: 'info',\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n subject: (p) => str(p.job_name),\n detail: (p) => `back on time at ${str(p.observed_at)}`,\n resolves: true,\n },\n // a generation run ended in a terminal failure (its held credits are released)\n 'job.generation.failed': {\n level: 'error',\n key: (p) => `jobs:generation:${str(p.error_class, 'unclassified')}`,\n subject: (p) => str(p.error_class, 'unclassified'),\n detail: (p) => `run ${str(p.run_id)} failed (${str(p.error_class, 'no error class')})`,\n },\n};\n\ninterface AuditRow { id?: string | number; event?: string; created_at?: string; payload?: Payload }\ninterface Item { item_id: string; version: number; data: Record<string, unknown> }\n// the schedule tick, or a hand invoke (`POST /v1/fn/incident-watch`) carrying `{ dry_run }`\ntype Env = CronFunctionEnvelope | HttpFunctionEnvelope<{ dry_run?: boolean }>;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const readKey = env.scoped_jwts?.['control-plane'];\n if (!cms || !readKey) {\n return Response.json({ error: 'missing cms or audit:read scope' }, { status: 503 });\n }\n const store = new Store(base, cms);\n\n // 1. the watermark\n const cursorRow = await store.byKey('__cursor__');\n if (!cursorRow) {\n const newest = await newestAuditId(base, readKey);\n await store.create({ key: '__cursor__', cursor: newest, last_seen: new Date().toISOString() });\n return Response.json({ initialized: true, cursor: newest, raised: 0 });\n }\n let cursor = String(cursorRow.data.cursor ?? '0');\n\n // 2. drain\n const rows: AuditRow[] = [];\n for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {\n const { rows: batch, next } = await drain(base, readKey, cursor);\n rows.push(...batch);\n if (batch.length > 0) cursor = String(batch[batch.length - 1]!.id ?? cursor);\n if (!next || batch.length === 0) break;\n cursor = next;\n }\n\n // 3 + 4. match and upsert\n let raised = 0;\n let resolved = 0;\n const now = new Date().toISOString();\n for (const row of rows) {\n const rule = RULES[String(row.event ?? '')];\n if (!rule) continue;\n const p = row.payload ?? {};\n const key = rule.key(p);\n const existing = await store.byKey(key);\n\n if (rule.resolves) {\n if (existing) {\n await store.patch(existing, {\n level: 'info', event: String(row.event), detail: rule.detail(p).slice(0, DETAIL_MAX),\n last_seen: now,\n });\n resolved++;\n }\n continue;\n }\n if (existing) {\n await store.patch(existing, {\n event: String(row.event),\n level: rule.level,\n subject: rule.subject(p),\n seen_count: Number(existing.data.seen_count ?? 0) + 1,\n detail: rule.detail(p).slice(0, DETAIL_MAX),\n last_seen: now,\n });\n } else {\n await store.create({\n key,\n event: String(row.event),\n level: rule.level,\n subject: rule.subject(p),\n seen_count: 1,\n first_seen: now,\n last_seen: now,\n detail: rule.detail(p).slice(0, DETAIL_MAX),\n });\n }\n raised++;\n }\n\n // 5. advance \u2014 If-Match, so exactly one overlapping run wins\n if (!env.payload?.dry_run) {\n await store.patch(cursorRow, { cursor, last_seen: now });\n }\n return Response.json({ scanned: rows.length, raised, resolved, cursor });\n },\n};\n\n// \u2500\u2500 the audit stream (ascending NDJSON; `x-vxil-next-after-id` continues it) \u2500\u2500\nasync function drain(\n base: string, key: string, afterId: string,\n): Promise<{ rows: AuditRow[]; next: string | null }> {\n const res = await fetch(\n `${base}/v1/audit/export?after_id=${encodeURIComponent(afterId)}&limit=500`,\n { headers: { authorization: `Bearer ${key}` } },\n );\n if (!res.ok) throw new Error(`audit export ${res.status}`);\n const rows = (await res.text()).split('\\n').filter(Boolean).map((l) => JSON.parse(l) as AuditRow);\n return { rows, next: res.headers.get('x-vxil-next-after-id') };\n}\nasync function newestAuditId(base: string, key: string): Promise<string> {\n const res = await fetch(`${base}/v1/audit?limit=1`, { headers: { authorization: `Bearer ${key}` } });\n if (!res.ok) throw new Error(`audit list ${res.status}`);\n const body = (await res.json()) as { data?: { events?: AuditRow[] } };\n return String(body.data?.events?.[0]?.id ?? '0');\n}\n\n// \u2500\u2500 the cms store (the REST envelope is { data: { items: [{ item_id, version, data }] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() {\n return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' };\n }\n async byKey(key: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${this.base}/v1/cms/items/incidents?filter=${filter}&limit=1`, {\n headers: this.h(),\n });\n if (!res.ok) return null;\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 \u2014 a concurrent run already claimed this unique key */\n async create(data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/incidents`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n /** false on 409 \u2014 a concurrent run already moved this row past `version` */\n async patch(item: Item, data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/incidents/${item.item_id}`, {\n method: 'PATCH',\n headers: { ...this.h(), 'if-match': String(item.version) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n",
19441
20052
  "process-batch.ts": "// process-batch.ts \u2014 THE WORKER (a vxil function).\n//\n// Trigger: queue. This function has no URL a browser can reach \u2014 it runs because\n// something was ENQUEUED. The envelope it receives is:\n// { trigger: 'queue', tenant_id, request_id, idempotency_key, vxil_base,\n// scoped_jwts, secrets, payload }\n// where `payload` is exactly the object you put inside the enqueue body's\n// `payload.payload`, and `idempotency_key` is stable per run.\n//\n// DELIVERY IS AT-LEAST-ONCE. A retry, an overlapping tick, or a replay can hand\n// you the same batch twice, so correctness cannot rest on \"it runs once\". Here\n// the `request_key` field on `renders` is declared `unique`, which turns the\n// second create into a clean 409 \u2014 and a 409 is not an error, it is the answer\n// \"already done\". That is the whole dedupe strategy: one declared field, and a\n// status code you agree to read as success.\n\nimport type { QueueFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = QueueFunctionEnvelope<{ batch_id?: string; items?: Array<{ key?: string; title?: string; credits?: number }> }>;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n const batchId = String(env.payload?.batch_id ?? env.idempotency_key ?? 'batch');\n const items = Array.isArray(env.payload?.items) ? env.payload!.items! : [];\n if (items.length === 0) return Response.json({ batch_id: batchId, created: 0, duplicates: 0 });\n\n let created = 0;\n let duplicates = 0;\n let rejected = 0;\n\n for (const [i, item] of items.entries()) {\n // Derive a stable key per item so the SAME batch always produces the SAME\n // keys \u2014 that is what makes the redelivery a duplicate rather than a copy.\n const requestKey = String(item.key ?? `${batchId}:${i}`);\n const res = await fetch(`${base}/v1/cms/items/renders`, {\n method: 'POST',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: {\n request_key: requestKey,\n title: String(item.title ?? requestKey),\n state: 'queued',\n run_id: env.idempotency_key ?? null,\n credits: typeof item.credits === 'number' ? item.credits : 0,\n created_at: new Date().toISOString(),\n },\n }),\n });\n if (res.ok) created++;\n else if (res.status === 409) duplicates++; // already processed \u2014 the point of `unique`\n else rejected++;\n }\n\n return Response.json({ batch_id: batchId, created, duplicates, rejected });\n },\n};\n"
19442
20053
  }
19443
20054
  },
@@ -20854,16 +21465,7 @@ function fail(msg, code = 1) {
20854
21465
  process.exit(code);
20855
21466
  }
20856
21467
  function positional(skip = 0) {
20857
- const out = [];
20858
- for (let i = 0; i < rest.length; i++) {
20859
- const t = rest[i];
20860
- if (t.startsWith("--")) {
20861
- if (rest[i + 1] && !rest[i + 1].startsWith("--")) i++;
20862
- continue;
20863
- }
20864
- out.push(t);
20865
- }
20866
- return out.slice(skip);
21468
+ return positionalArgs(rest, skip);
20867
21469
  }
20868
21470
  async function prompt(q, opts = {}) {
20869
21471
  const stdin = process.stdin;
@@ -20928,7 +21530,8 @@ function makeDash(base2) {
20928
21530
  }
20929
21531
  function linkRequest(slug, tenantId, base2) {
20930
21532
  const as = flag("as");
20931
- return { slug, tenantId, edgeUrl: base2, env: flag("env") ?? envLabelFor(base2), ...as !== void 0 ? { as } : {} };
21533
+ const env = flag("env") ?? keptEnvLabel(loadProject(), slug, tenantId, as) ?? envLabelFor(base2);
21534
+ return { slug, tenantId, edgeUrl: base2, env, ...as !== void 0 ? { as } : {} };
20932
21535
  }
20933
21536
  function persistLink(slug, tenantId, apiKey, base2) {
20934
21537
  const req = linkRequest(slug, tenantId, base2);
@@ -21428,6 +22031,7 @@ async function runApply(apply, sel = "prod", opts = {}) {
21428
22031
  }
21429
22032
  }
21430
22033
  {
22034
+ if (!gate) for (const w of await authMailPushWarnings(cfg, api)) console.error(`vxil: warning \u2014 ${w}`);
21431
22035
  const typed = hookTypePreflight(cfg);
21432
22036
  for (const w of typed.warnings) console.error(`vxil: warning \u2014 cms ${w}`);
21433
22037
  if (typed.errors.length) {
@@ -21455,10 +22059,19 @@ async function runApply(apply, sel = "prod", opts = {}) {
21455
22059
  report.errors++;
21456
22060
  }
21457
22061
  };
22062
+ let cmsApi = api;
22063
+ if (apply) {
22064
+ const enabledAt = await enableCmsBeforeSchema({ api, cfg });
22065
+ if (enabledAt !== null) {
22066
+ console.log(`
22067
+ cms: enabled (v${enabledAt}) before its schema \u2014 the declared cms config follows in the config step`);
22068
+ cmsApi = retryWhileCmsPropagates(api, { onWait: (line) => console.log(line) });
22069
+ }
22070
+ }
21458
22071
  await runSection("cms.schema", async () => {
21459
22072
  if (!cfg.cms?.collections || !Object.keys(cfg.cms.collections).length) return;
21460
22073
  const r = await planCmsSchema({
21461
- api,
22074
+ api: cmsApi,
21462
22075
  collections: cfg.cms.collections,
21463
22076
  apply,
21464
22077
  allowDestructive: hasFlag("allow-destructive"),
@@ -21737,7 +22350,7 @@ vxil diff \u2192 ${report.drift} drift item(s), ${report.ignored} ignored, ${rep
21737
22350
  if (!apply) console.log("\n(run `vxil push` to apply)");
21738
22351
  if (apply && !hasFlag("no-gen")) await runGen(sel);
21739
22352
  if (apply) {
21740
- const exitWith = pushExitCode(pushProblems, strictPush);
22353
+ const exitWith = pushExitCode(pushProblems, strictPush, target.slot === "primary" && target.tenantSlug ? { slug: target.tenantSlug } : {});
21741
22354
  for (const line of exitWith.lines) console.error(`vxil: ${line}`);
21742
22355
  return exitWith.code;
21743
22356
  }
@@ -21752,6 +22365,21 @@ async function backendListsFunctions(api) {
21752
22365
  return false;
21753
22366
  }
21754
22367
  }
22368
+ async function authMailPushWarnings(cfg, api) {
22369
+ const notif = cfg.features?.notifications;
22370
+ const rows = authMailDependencyChecks(
22371
+ cfg.features?.auth,
22372
+ !!notif && notif.enabled !== false
22373
+ );
22374
+ if (!rows.length) return [];
22375
+ try {
22376
+ const res = await api("GET", "/v1/features");
22377
+ const list = res.status === 200 ? res.body.data?.features : void 0;
22378
+ if (Array.isArray(list) && list.includes("notifications")) return [];
22379
+ } catch {
22380
+ }
22381
+ return rows.map((r) => r.detail ?? "");
22382
+ }
21755
22383
  async function runServerApply(sel) {
21756
22384
  armServerApplyJsonFail();
21757
22385
  const { api, target } = requireApi(sel, { write: "push --server" });
@@ -21836,6 +22464,7 @@ ${formatTriggerWiring(w).join("\n")}`);
21836
22464
  say(`functions: skipped (--skip-functions) \u2014 ${Object.keys(declaredFns).length} declared function(s) not deployed`);
21837
22465
  }
21838
22466
  {
22467
+ for (const w of await authMailPushWarnings(cfg, api)) say(`vxil: warning \u2014 ${w}`);
21839
22468
  const typed = hookTypePreflight(cfg);
21840
22469
  for (const w of typed.warnings) say(`vxil: warning \u2014 cms ${w}`);
21841
22470
  if (typed.errors.length) {
@@ -21873,15 +22502,11 @@ ${formatTriggerWiring(w).join("\n")}`);
21873
22502
  say("\napplying server-side\u2026");
21874
22503
  }
21875
22504
  });
21876
- if (done.status === "running") {
21877
- const errored = done.steps.filter((st) => st.status === "pending" && st.error);
21878
- const onlyDeferredApiState = errored.length > 0 && errored.every((st) => st.kind === "api-state" && (st.error ?? "").startsWith("deferred:"));
21879
- if (onlyDeferredApiState) {
21880
- report(done, false);
21881
- say(" \u2026 declared API state deferred (the feature config is still propagating) \u2014 waiting 30 s, then resuming the same apply");
21882
- await new Promise((r) => setTimeout(r, 3e4));
21883
- done = await serverApply({ api, resume: done.apply_id });
21884
- }
22505
+ for (let attempt = 1; done.status === "running" && onlyPropagationDeferred(done.steps) && attempt <= CMS_PROPAGATION_ATTEMPTS; attempt++) {
22506
+ if (attempt === 1) report(done, false);
22507
+ say(` \u2026 deferred: the feature config is still propagating \u2014 waiting ${CMS_PROPAGATION_DELAY_MS / 1e3} s, then resuming the same apply (${attempt}/${CMS_PROPAGATION_ATTEMPTS})`);
22508
+ await new Promise((r) => setTimeout(r, CMS_PROPAGATION_DELAY_MS));
22509
+ done = await serverApply({ api, resume: done.apply_id });
21885
22510
  }
21886
22511
  report(done, true);
21887
22512
  const problems = { hard: [], strict: [] };
@@ -21939,25 +22564,32 @@ async function runSeed(sel, opts = {}) {
21939
22564
  const cfg = await loadVxilConfig();
21940
22565
  const seed = cfg.seed;
21941
22566
  if (!seed) {
21942
- if (opts.optional) return;
22567
+ if (opts.optional) {
22568
+ console.log(" (no `seed` block in vxil.config \u2014 nothing to seed)");
22569
+ return 0;
22570
+ }
21943
22571
  fail("no `seed` block in vxil.config");
21944
22572
  }
21945
- let total = 0;
22573
+ const tally = newSeedTally();
21946
22574
  if (seed.users?.length) {
21947
22575
  const res = await api("POST", "/v1/users/bulk", { users: seed.users });
21948
- if (res.status !== 200) printEnvelope(res);
21949
- total += seed.users.length;
21950
- console.log(` \u2713 ${seed.users.length} users`);
22576
+ const ok = res.status === 200 || res.status === 201;
22577
+ if (!ok) printEnvelope(res);
22578
+ tallySeed(tally, res.status, seed.users.length, res.body.error?.code);
22579
+ console.log(ok ? ` \u2713 ${seed.users.length} users` : ` \u2717 users: ${res.status}`);
21951
22580
  }
21952
22581
  for (const block of seed.cms ?? []) {
22582
+ const before = { ...tally };
21953
22583
  for (const item of block.items) {
21954
22584
  const res = await api("POST", `/v1/cms/items/${encodeURIComponent(block.collection)}`, { data: item });
21955
- if (res.status !== 201 && res.status !== 200) printEnvelope(res);
21956
- total++;
22585
+ const code = res.body.error?.code;
22586
+ if (res.status !== 201 && res.status !== 200 && !(res.status === 409 && code === "unique_violation")) printEnvelope(res);
22587
+ tallySeed(tally, res.status, 1, code);
21957
22588
  }
21958
- console.log(` \u2713 ${block.items.length} ${block.collection} items`);
22589
+ console.log(` ${tally.failed > before.failed ? "\u2717" : "\u2713"} ${block.collection}: ${formatSeedTally(tally, before)}`);
21959
22590
  }
21960
- console.log(`seeded ${total} record(s)`);
22591
+ console.log(`seeded ${tally.created} record(s)${tally.present ? ` \xB7 ${tally.present} already present` : ""}${tally.failed ? ` \xB7 ${tally.failed} FAILED` : ""}`);
22592
+ return tally.failed ? 1 : 0;
21961
22593
  }
21962
22594
  async function runGen(sel = "prod", opts = {}) {
21963
22595
  const offline = opts.offline ?? hasFlag("offline");
@@ -22142,6 +22774,22 @@ async function runDoctor() {
22142
22774
  ));
22143
22775
  }
22144
22776
  }
22777
+ if (live.has("auth") && !live.has("notifications")) {
22778
+ const ares = await api("GET", "/v1/config/auth");
22779
+ if (ares.status === 200) {
22780
+ checks.push(...authMailDependencyChecks(ares.body.data?.manifest, false));
22781
+ }
22782
+ }
22783
+ if (live.has("vector-search")) {
22784
+ const vres = await api("GET", "/v1/config/vector-search");
22785
+ if (vres.status === 200) {
22786
+ const cres = await api("GET", "/v1/search/collections");
22787
+ checks.push(...embedProviderChecks(
22788
+ vres.body.data?.manifest,
22789
+ cres.status === 200 ? cres.body.data?.collections ?? [] : null
22790
+ ));
22791
+ }
22792
+ }
22145
22793
  if (live.has("payments")) {
22146
22794
  const pres = await api("GET", "/v1/config/payments");
22147
22795
  if (pres.status === 200) {
@@ -22184,7 +22832,7 @@ async function runDoctor() {
22184
22832
  try {
22185
22833
  local.set(n, sourceSha12(
22186
22834
  (await bundleFunction(resolve7(process.cwd(), def.entry), { projectRoot: process.cwd() })).source,
22187
- recordedFunctionsRuntime(lres.body.data?.runtime ?? FUNCTIONS_RUNTIME)
22835
+ recordedFunctionsRuntime(lres.body.data?.runtime)
22188
22836
  ));
22189
22837
  } catch (e) {
22190
22838
  checks.push({ name: `function '${n}' bundle`, ok: false, detail: e.message.split("\n")[0] ?? "bundle failed" });
@@ -22279,11 +22927,19 @@ function scaffoldProject(configSrc, fns) {
22279
22927
  const gi = resolve7(cwd, ".gitignore");
22280
22928
  const ignore = ["", "# vxil", ".vxil/", ".env.local", ""].join("\n");
22281
22929
  if (!existsSync8(gi) || !readFileSync8(gi, "utf8").includes(".vxil/")) appendFileSync2(gi, ignore);
22282
- const pkg = ensureScaffoldPackageJson(cwd, { sdkTypes: entries.some(([, src]) => src.includes("from '@vxil/sdk'")) });
22930
+ const freshFolder = !existsSync8(resolve7(cwd, "package.json"));
22931
+ const tsconfig = freshFolder ? ensureScaffoldTsconfig(cwd) : existsSync8(resolve7(cwd, "tsconfig.json")) ? "exists" : "suggested";
22932
+ const pkg = ensureScaffoldPackageJson(cwd, {
22933
+ sdkTypes: entries.some(([, src]) => src.includes("from '@vxil/sdk'")),
22934
+ nodeTypes: tsconfig === "created"
22935
+ });
22283
22936
  if (pkg.action === "skipped") console.log(` ! package.json not patched: ${pkg.reason}`);
22284
- return pkg;
22937
+ if (tsconfig === "suggested") {
22938
+ console.log(' (no tsconfig.json written into an existing app \u2014 to type-check vxil.config.ts / functions, use moduleResolution "bundler" and add @types/node)');
22939
+ }
22940
+ return { ...pkg, tsconfig };
22285
22941
  }
22286
- function initProject() {
22942
+ function initProject(opts = {}) {
22287
22943
  const cwd = process.cwd();
22288
22944
  const cfgPath = resolve7(cwd, "vxil.config.ts");
22289
22945
  if (existsSync8(cfgPath) && !hasFlag("force")) fail("vxil.config.ts already exists (use --force to overwrite)");
@@ -22291,8 +22947,12 @@ function initProject() {
22291
22947
  const tpl = TEMPLATE_CATALOG.find((t) => t.id === template);
22292
22948
  if (!tpl) fail(`unknown template '${template}' \u2014 run \`vxil templates\` to list the gallery, or choose: ${TEMPLATE_CATALOG.map((t) => t.id).join(", ")}`);
22293
22949
  const pkgRes = scaffoldProject(tpl.configSrc, tpl.functions);
22950
+ const readmePath = resolve7(cwd, "README.md");
22951
+ const readme = tpl.readme && !existsSync8(readmePath);
22952
+ if (readme) writeFileSync6(readmePath, tpl.readme);
22294
22953
  console.log(`scaffolded from template '${tpl.id}' (${tpl.title}):`);
22295
22954
  console.log(` vxil.config.ts # ${tpl.summary}`);
22955
+ if (readme) console.log(" README.md # the template's walkthrough: setup, secrets, what to try");
22296
22956
  console.log(` # collections: ${tpl.collections.join(", ")} \xB7 features: ${tpl.features.join(", ")}`);
22297
22957
  const fnFiles = Object.keys(tpl.functions ?? {});
22298
22958
  if (fnFiles.length > 0) for (const f of fnFiles) console.log(` functions/${f.padEnd(8)} # the template's function`);
@@ -22302,8 +22962,10 @@ function initProject() {
22302
22962
  const sdk = Object.values(tpl.functions ?? {}).some((src) => src.includes("from '@vxil/sdk'"));
22303
22963
  console.log(` package.json # declares @vxil/config (the config import)${sdk ? " + @vxil/sdk (the functions' envelope types)" : ""}`);
22304
22964
  }
22965
+ if (pkgRes.tsconfig === "created") console.log(' tsconfig.json # minimal: moduleResolution bundler, types ["node"]');
22966
+ if (opts.fromQuickstart) return;
22305
22967
  console.log("\nnext: `vxil quickstart` (new tenant) or `vxil link <slug>` (existing), then `vxil push`.");
22306
- console.log(" (`npm install` is optional \u2014 it gives your editor the types; the CLI runs vxil.config.ts and bundles functions without it.)");
22968
+ console.log(" (the CLI runs vxil.config.ts and bundles functions without `npm install`; run it to type-check \u2014 tsc and your editor need the installed types.)");
22307
22969
  }
22308
22970
  function listTemplates() {
22309
22971
  console.log("vxil templates \u2014 the Blueprint Gallery. Scaffold one with `vxil init --template <id>`.\n");
@@ -22322,6 +22984,10 @@ function listTemplates() {
22322
22984
  process.exit(0);
22323
22985
  }
22324
22986
  if (help && "delegate" in help) normalizeHelpArgv(rest);
22987
+ const unknown = isGlobalHelp(cmd) ? [] : unknownFlags(rest);
22988
+ if (unknown.length) {
22989
+ console.error(`vxil: warning \u2014 unknown flag${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} ignored (\`vxil ${cmd} --help\` lists this verb's flags)`);
22990
+ }
22325
22991
  }
22326
22992
  try {
22327
22993
  switch (cmd) {
@@ -22332,7 +22998,15 @@ try {
22332
22998
  break;
22333
22999
  }
22334
23000
  case "init": {
23001
+ const [dir, extra] = positional(0);
23002
+ if (extra !== void 0) fail(VERB_USAGE.init);
23003
+ if (dir !== void 0) {
23004
+ mkdirSync5(resolve7(process.cwd(), dir), { recursive: true });
23005
+ process.chdir(resolve7(process.cwd(), dir));
23006
+ console.log(`(scaffolding into ${dir}/)`);
23007
+ }
22335
23008
  initProject();
23009
+ if (dir !== void 0) console.log(` (run those from ${dir}/ \u2014 \`cd ${dir}\` first)`);
22336
23010
  break;
22337
23011
  }
22338
23012
  case "templates": {
@@ -22348,11 +23022,14 @@ try {
22348
23022
  const email = flag("email") ?? await prompt("email: ");
22349
23023
  const password = flag("password") ?? await prompt("password (>=10 chars): ", { hidden: true });
22350
23024
  const appName = flag("name") ?? "vxil-app";
23025
+ const wl = quickstartWorkload(flag("workload"), flag("env"), hasFlag("dev"));
23026
+ if ("error" in wl) fail(wl.error);
22351
23027
  const body = {
22352
23028
  email,
22353
23029
  password,
22354
23030
  app_name: appName,
22355
23031
  features,
23032
+ workload: wl.workload,
22356
23033
  // --dev mints an ephemeral preview tenant the server-side TTL reaper
22357
23034
  // tears down after --ttl hours (default 72) — the per-PR CI backend.
22358
23035
  ...hasFlag("dev") ? { kind: "dev", ttl_hours: ttlFlag(72) } : {},
@@ -22376,14 +23053,14 @@ try {
22376
23053
  const creds = loadCredentials();
22377
23054
  creds.keys = { ...creds.keys, [d.app_slug]: d.api_key };
22378
23055
  saveCredentials(creds);
22379
- const slot = { tenant_id: d.tenant_id, slug: d.app_slug, edge_url: d.edge_url ?? base2, env: flag("env") ?? envLabelFor(base2) };
23056
+ const slot = { tenant_id: d.tenant_id, slug: d.app_slug, edge_url: d.edge_url ?? base2, env: flag("env") ?? workloadEnvLabel(wl.workload) };
22380
23057
  const merged = mergePrimaryBinding(loadProject(), slot);
22381
23058
  saveProject(hasFlag("dev") ? { ...merged, dev: { tenant_id: slot.tenant_id, slug: slot.slug, edge_url: slot.edge_url } } : merged);
22382
- const envNotice = quickstartEnvNotice(flag("env"), base2, d.app_slug, { dev: hasFlag("dev") });
23059
+ const envNotice = quickstartEnvNotice(flag("env"), base2, d.app_slug, { dev: hasFlag("dev"), label: slot.env });
22383
23060
  if (envNotice) console.error(`vxil: ${envNotice}`);
22384
- if (!existsSync8(resolve7(process.cwd(), "vxil.config.ts"))) initProject();
23061
+ if (!existsSync8(resolve7(process.cwd(), "vxil.config.ts"))) initProject({ fromQuickstart: true });
22385
23062
  console.log(`
22386
- \u2713 tenant '${d.app_slug}' created \xB7 enabled: ${(d.enabled ?? []).join(", ")}`);
23063
+ \u2713 tenant '${d.app_slug}' created (${wl.workload}) \xB7 enabled: ${(d.enabled ?? []).join(", ")}`);
22387
23064
  if (d.expires_at) console.log(` dev tenant \u2014 expires ${d.expires_at} (the nightly reaper tears it down; extend by recreating)`);
22388
23065
  if (!hasFlag("no-push")) {
22389
23066
  console.log("\napplying your config (cms schema \u2192 config \u2192 functions) + generating types\u2026");
@@ -22411,11 +23088,12 @@ try {
22411
23088
  console.log(` types: ./vxil.types.ts (generated \u2014 re-run \`vxil gen\` after a schema change)`);
22412
23089
  console.log(` try it:`);
22413
23090
  console.log(` import { Vxil } from '@vxil/sdk';`);
22414
- console.log(` import type { VxilSchema } from './vxil.types';`);
22415
- console.log(` const vx = Vxil.connect<VxilSchema>({ apiKey }); // narrows to YOUR enabled features`);
23091
+ console.log(` import type { VxilSchema } from './vxil.types.js';`);
23092
+ console.log(` const vx = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! }); // narrows to YOUR enabled features`);
22416
23093
  console.log(` await vx.from('${firstCollection}').create({ /* field-typed */ });`);
22417
23094
  console.log(`
22418
23095
  key stored in ~/.vxil/credentials.json \xB7 project bound in .vxil/project.json`);
23096
+ console.log(" next: `vxil env pull` writes VXIL_API_KEY (+ VXIL_BASE_URL, VXIL_TENANT_ID) to .env.local for the snippet above");
22419
23097
  break;
22420
23098
  }
22421
23099
  case "try": {
@@ -22903,12 +23581,15 @@ try {
22903
23581
  if (!target) fail(`no project '${arg}' on this account \u2014 \`vxil projects list\``);
22904
23582
  console.error(`vxil projects workload ${arg} ${w.workload} \u2192 ${host}`);
22905
23583
  const { change } = await withDashSession(dash, base2, (c) => setProjectWorkloadViaSession(dash, c, target.id, w.workload));
22906
- if (jsonOut) console.log(JSON.stringify({ slug: arg, ...change }, null, 2));
23584
+ const relabel = relabelBindingForWorkload(loadProject(), target.id, w.workload);
23585
+ if (relabel) saveProject(relabel.binding);
23586
+ if (jsonOut) console.log(JSON.stringify({ slug: arg, ...change, ...relabel ? { relabeled: relabel.relabeled } : {} }, null, 2));
22907
23587
  else {
22908
23588
  const out = renderWorkloadChange(arg, change);
22909
23589
  console.log(out.stdout);
22910
23590
  for (const l of out.stderr) console.error(l);
22911
23591
  }
23592
+ if (relabel) console.error(`relabeled this repo's ${relabel.relabeled.join(", ")} binding as '${workloadEnvLabel(w.workload)}' (.vxil/project.json)`);
22912
23593
  break;
22913
23594
  }
22914
23595
  if (!arg) fail(PROJECTS_USAGE);
@@ -23265,15 +23946,7 @@ ${plan.rationale}`);
23265
23946
  const cres = await api("GET", "/v1/cms/collections");
23266
23947
  const collections = cres.body.data?.collections ?? [];
23267
23948
  const cmsBlock = {};
23268
- for (const col of collections) {
23269
- const fields = {};
23270
- for (const fd of col.fields) fields[fd.field] = remoteFieldToConfig(fd);
23271
- cmsBlock[col.collection] = {
23272
- ...col.singular ? { singular: col.singular } : {},
23273
- ...col.owner_field ? { ownerField: col.owner_field } : {},
23274
- fields
23275
- };
23276
- }
23949
+ for (const col of collections) cmsBlock[col.collection] = pulledCollectionBlock(col);
23277
23950
  const src = `import { defineConfig } from '@vxil/config';
23278
23951
 
23279
23952
  // Materialized from the live tenant by \`vxil pull\` on ${(/* @__PURE__ */ new Date()).toISOString()}.
@@ -23365,7 +24038,8 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
23365
24038
  break;
23366
24039
  }
23367
24040
  case "seed": {
23368
- await runSeed(selector());
24041
+ const code = await runSeed(selector(), { optional: hasFlag("dev") });
24042
+ if (code !== 0) process.exitCode = code;
23369
24043
  break;
23370
24044
  }
23371
24045
  case "export": {
@@ -23431,10 +24105,32 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
23431
24105
  case "import": {
23432
24106
  const feature = positional(0)[0];
23433
24107
  const file = flag("file");
23434
- if (!feature || !file) fail("usage: vxil import <feature> --file <bundle.json> [--mode merge|replace]");
24108
+ if (!feature || !file) fail("usage: vxil import <feature> --file <bundle.json|export.ndjson> [--mode merge|replace] [--allow-split-links]");
24109
+ const text = readFileSync8(resolve7(process.cwd(), file), "utf8");
24110
+ const mode = flag("mode") ?? "merge";
24111
+ if (isNdjsonExport(text)) {
24112
+ let pieces;
24113
+ try {
24114
+ pieces = ndjsonImportPieces(parseNdjsonExport(text));
24115
+ } catch (e) {
24116
+ fail(`${file}: ${e.message}`);
24117
+ }
24118
+ if (pieces.length > 1 && mode === "replace") {
24119
+ fail(`${file} needs ${pieces.length} import pieces, and a replace would delete each earlier piece's rows \u2014 import it with --mode merge (insert-only), into the project the export came from for an exact restore`);
24120
+ }
24121
+ const { api: api2, target } = requireApi(selector(), { write: `import ${feature}` });
24122
+ const tenant = knownTenantId(target.tenantId) ? target.tenantId : void 0;
24123
+ const refusal = crossProjectSplitRefusal(pieces, tenant);
24124
+ const crossProject = refusal !== null;
24125
+ if (refusal && !hasFlag("allow-split-links")) fail(`${file}: ${refusal}`);
24126
+ if (refusal) console.error("vxil: note \u2014 --allow-split-links: links between rows in different pieces keep the source id");
24127
+ if (pieces.length > 1) console.error(`vxil: importing ${file} in ${pieces.length} pieces (merge is insert-only: a re-run skips what is already in)`);
24128
+ printEnvelope(await importNdjson(api2, feature, pieces, mode, { crossProject }));
24129
+ break;
24130
+ }
23435
24131
  const { api } = requireApi(selector(), { write: `import ${feature}` });
23436
- const bundle = JSON.parse(readFileSync8(resolve7(process.cwd(), file), "utf8"));
23437
- printEnvelope(await api("POST", `/v1/import/${feature}`, { bundle, mode: flag("mode") ?? "merge" }));
24132
+ const bundle = JSON.parse(text);
24133
+ printEnvelope(await api("POST", `/v1/import/${feature}`, { bundle, mode }));
23438
24134
  break;
23439
24135
  }
23440
24136
  case "migrate": {
@@ -23559,7 +24255,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
23559
24255
  else console.log(`\u2713 disabled '${name}' (functions config v${r.version ?? "?"})`);
23560
24256
  for (const l of disableNotices(name, declared, r.version)) console.error(l);
23561
24257
  } else if (sub === "dev") {
23562
- if (!name) fail("usage: vxil functions dev <name> [--port <n>] [--trigger <http|webhook|cms-hook|auth-hook|queue|cron>] \xB7 vxil functions dev <name> --event <file.json> [--trigger <lane>] [--run-id <id>] (no dev slot + a production primary: a non-http lane or --event asks for the slug, or --yes)");
24258
+ if (!name) fail("usage: vxil functions dev <name> [--port <n>] [--trigger <http|webhook|cms-hook|auth-hook|queue|cron>] \xB7 vxil functions dev <name> --event <file.json> [--trigger <lane>] [--run-id <id>] (no dev slot + any non-dev binding: a non-http lane or --event asks for the slug, or --yes)");
23563
24259
  await runFunctionsDev(name);
23564
24260
  } else {
23565
24261
  fail("usage: vxil functions new|deploy|list|delete|enable|disable|invoke|logs|dev <name>");
@@ -23802,15 +24498,33 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
23802
24498
  async function devUpCreate(dashBase, appName, ttlHours) {
23803
24499
  const cfg = await loadVxilConfig();
23804
24500
  const feats = Object.keys(cfg.features);
23805
- const email = flag("email") ?? process.env.VXIL_DEV_EMAIL;
23806
- const password = flag("password") ?? process.env.VXIL_DEV_PASSWORD;
23807
- if (!email || !password) fail("creating a dev tenant needs --email/--password (or VXIL_DEV_EMAIL/VXIL_DEV_PASSWORD)");
23808
- const d = await createBranchTenant(makeDash(dashBase), {
24501
+ const dash = makeDash(dashBase);
24502
+ let email = flag("email") || process.env.VXIL_DEV_EMAIL || void 0;
24503
+ let password = flag("password") || process.env.VXIL_DEV_PASSWORD || void 0;
24504
+ const inviteCode = flag("invite") || process.env.VXIL_DEV_INVITE || void 0;
24505
+ let d;
24506
+ if (!email || !password) {
24507
+ const session = sessionForTarget(dashBase);
24508
+ if (session) {
24509
+ const r = await createDevTenantViaSession(dash, session, { appName, ttlHours });
24510
+ if ("tenant" in r) d = r.tenant;
24511
+ else console.error("vxil: the stored dashboard session expired \u2014 signing in instead");
24512
+ }
24513
+ if (!d) {
24514
+ if (!process.stdin.isTTY || !process.stdout.isTTY || jsonOut) {
24515
+ fail("creating a dev tenant needs a dashboard session \u2014 run `vxil login`, or pass --email/--password (or set VXIL_DEV_EMAIL/VXIL_DEV_PASSWORD; add --invite <code> / VXIL_DEV_INVITE for a new account on an invite-only backend)");
24516
+ }
24517
+ email ??= await prompt("dashboard email: ");
24518
+ password ??= await prompt("password: ", { hidden: true });
24519
+ }
24520
+ }
24521
+ d ??= await createBranchTenant(dash, {
23809
24522
  email,
23810
24523
  password,
23811
24524
  appName,
23812
24525
  features: feats,
23813
- ttlHours
24526
+ ttlHours,
24527
+ ...inviteCode ? { inviteCode } : {}
23814
24528
  });
23815
24529
  const creds = loadCredentials();
23816
24530
  creds.keys = { ...creds.keys, [d.slug]: d.api_key };
@@ -23835,7 +24549,8 @@ async function runDev(sub) {
23835
24549
  });
23836
24550
  await runDev("up");
23837
24551
  const pushCode = await runApply(true, { kind: "dev" });
23838
- await runSeed({ kind: "dev" }, { optional: true });
24552
+ const seedCode = await runSeed({ kind: "dev" }, { optional: true });
24553
+ if (seedCode !== 0 && pushCode === 0) process.exitCode = seedCode;
23839
24554
  if (pushCode !== 0) {
23840
24555
  process.exitCode = pushCode;
23841
24556
  console.log("\u2713 dev reset complete (torn down \u2192 recreated \u2192 pushed \u2192 seeded) \u2014 the push reported a problem above (exit 1)");
@@ -23848,6 +24563,27 @@ async function runDev(sub) {
23848
24563
  }
23849
24564
  async function runDevUp(dash, base2, proj) {
23850
24565
  const ttlHours = ttlFlag(72);
24566
+ const reuseSlug = flag("reuse-slug");
24567
+ if (reuseSlug !== void 0 && !hasFlag("new")) {
24568
+ const dashFn = makeDash(dash);
24569
+ const cookie = await resolveDashCookie(dashFn, dash);
24570
+ const r = await reuseDevTenantBySlug(dashFn, cookie, reuseSlug);
24571
+ if ("unauthorized" in r) fail("the dashboard session was refused \u2014 run `vxil login` (or pass --email/--password)");
24572
+ if (r.tenant) {
24573
+ let expiresAt = r.tenant.expires_at;
24574
+ const ext = await devExtend(dashFn, cookie, { tenant_id: r.tenant.tenant_id }, ttlHours).catch(() => null);
24575
+ if (ext && "extended" in ext) expiresAt = ext.expires_at ?? expiresAt;
24576
+ const creds2 = loadCredentials();
24577
+ creds2.keys = { ...creds2.keys, [r.tenant.slug]: r.tenant.api_key };
24578
+ saveCredentials(creds2);
24579
+ const binding2 = loadProject() ?? { tenant_id: r.tenant.tenant_id, slug: r.tenant.slug, edge_url: base2 };
24580
+ binding2.dev = { tenant_id: r.tenant.tenant_id, slug: r.tenant.slug, edge_url: base2, ...expiresAt ? { expires_at: expiresAt } : {} };
24581
+ saveProject(binding2);
24582
+ console.log(`\u2713 reusing '${r.tenant.slug}' (found on the account; a fresh key was minted)${expiresAt ? ` \u2014 expires ${expiresAt}` : ""}. Use \`vxil push --dev\` against it.`);
24583
+ return;
24584
+ }
24585
+ console.log(` no project '${reuseSlug}' on the account yet \u2014 creating it`);
24586
+ }
23851
24587
  const creds = loadCredentials();
23852
24588
  const decision = hasFlag("new") ? { create: true } : pickDevSlot(proj, /* @__PURE__ */ new Date(), (slug) => Boolean(creds.keys?.[slug]));
23853
24589
  if ("reuse" in decision) {
@@ -23887,7 +24623,7 @@ async function runDevUp(dash, base2, proj) {
23887
24623
  return;
23888
24624
  }
23889
24625
  }
23890
- const name = flag("name") ?? `${proj?.slug ?? "app"}-dev`;
24626
+ const name = reuseSlug ?? flag("name") ?? `${proj?.slug ?? "app"}-dev`;
23891
24627
  const d = await devUpCreate(dash, name, ttlHours);
23892
24628
  const binding = loadProject() ?? { tenant_id: d.tenant_id, slug: d.slug, edge_url: d.edge_url ?? base2 };
23893
24629
  binding.dev = {
@@ -24229,7 +24965,7 @@ async function runMcpInstall() {
24229
24965
  const mint = await dashFetch(mintDashBase, "POST", `/dashboard/tenants/${proj.tenant_id}/api-keys`, {
24230
24966
  name,
24231
24967
  agent: client,
24232
- ...scopesFlag ? { scopes: scopesFlag.split(",").map((s) => s.trim()).filter(Boolean) } : {}
24968
+ ...scopesFlag ? { scopes: mcpKeyScopes(scopesFlag) } : {}
24233
24969
  }, mintSession);
24234
24970
  if (mint.status === 201 || mint.status === 200) {
24235
24971
  const d = mint.json.data ?? mint.json;
@@ -24402,6 +25138,8 @@ Type ${t.tenantSlug ? `the tenant slug '${t.tenantSlug}'` : "'yes'"} to run it a
24402
25138
  } catch {
24403
25139
  fail("`vxil functions dev` runs your function under miniflare (the local Workers runtime), an optional dependency of the CLI \u2014 install it with `npm i -D miniflare` (or pnpm/yarn) and re-run.");
24404
25140
  }
25141
+ const tooOld = localRuntimeTooOld(installedLocalRuntimeDate(), FUNCTIONS_RUNTIME.compatibilityDate);
25142
+ if (tooOld) fail(tooOld);
24405
25143
  const cwd = process.cwd();
24406
25144
  const entry = resolve7(cwd, def.entry);
24407
25145
  const scopes = def.scopes ?? [];