@vxil/cli 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/vxil.js CHANGED
@@ -11,7 +11,7 @@ import { homedir as homedir2, hostname } from "node:os";
11
11
 
12
12
  // src/lib.ts
13
13
  import { pathToFileURL, fileURLToPath } from "node:url";
14
- import { resolve, dirname, basename } from "node:path";
14
+ import { resolve, dirname, basename, join } from "node:path";
15
15
  import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
16
16
  function lowerTriggerBindings(trigger) {
17
17
  const t = trigger?.kind ?? "http";
@@ -32,7 +32,7 @@ function lowerTriggerBindings(trigger) {
32
32
  return [{ kind: "http", ...str("path") ? { path: str("path") } : {} }];
33
33
  }
34
34
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
35
- var VXIL_CONFIG_PKG_VERSION = "0.2.0";
35
+ var VXIL_CONFIG_PKG_VERSION = "0.3.1";
36
36
  function ensureScaffoldPackageJson(cwd) {
37
37
  const file = resolve(cwd, "package.json");
38
38
  const spec = `^${VXIL_CONFIG_PKG_VERSION}`;
@@ -63,15 +63,39 @@ function ensureScaffoldPackageJson(cwd) {
63
63
  writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n");
64
64
  return { action: "added" };
65
65
  }
66
- function monorepoConfigAlias() {
66
+ var CONFIG_IMPORT_SPECIFIERS = ["@vxil/config", "vxil/config"];
67
+ function ownConfigImpl() {
67
68
  try {
68
69
  const here = dirname(fileURLToPath(import.meta.url));
69
- const src = resolve(here, "..", "..", "config", "src", "index.ts");
70
- if (existsSync(src)) return { "vxil/config": src, "@vxil/config": src };
70
+ const workspaceSrc = resolve(here, "..", "..", "config", "src", "index.ts");
71
+ const bundled = resolve(here, "config.js");
72
+ return {
73
+ ...existsSync(workspaceSrc) ? { workspaceSrc } : {},
74
+ ...existsSync(bundled) ? { bundled } : {}
75
+ };
71
76
  } catch {
72
77
  }
73
78
  return {};
74
79
  }
80
+ function configImportAlias(file, own = ownConfigImpl()) {
81
+ if (own.workspaceSrc) {
82
+ return Object.fromEntries(CONFIG_IMPORT_SPECIFIERS.map((s) => [s, own.workspaceSrc]));
83
+ }
84
+ if (!own.bundled) return {};
85
+ const map = {};
86
+ for (const spec of CONFIG_IMPORT_SPECIFIERS) {
87
+ if (!projectHasPackage(dirname(file), spec)) map[spec] = own.bundled;
88
+ }
89
+ return map;
90
+ }
91
+ function projectHasPackage(fromDir, spec) {
92
+ const parts = spec.split("/");
93
+ const pkgName = spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
94
+ for (let d = fromDir; ; d = dirname(d)) {
95
+ if (existsSync(join(d, "node_modules", pkgName, "package.json"))) return true;
96
+ if (dirname(d) === d) return false;
97
+ }
98
+ }
75
99
  async function importConfigModule(file) {
76
100
  if (!file.endsWith(".ts")) {
77
101
  return await import(pathToFileURL(file).href);
@@ -86,8 +110,8 @@ async function importConfigModule(file) {
86
110
  write: false,
87
111
  packages: "external",
88
112
  // keep vxil/config etc. external → resolved at runtime
89
- alias: monorepoConfigAlias(),
90
- // …except in-monorepo, where it is inlined
113
+ alias: configImportAlias(file),
114
+ // …except @vxil/config when the tenant lacks it
91
115
  absWorkingDir: dirname(file)
92
116
  });
93
117
  const out = result.outputFiles?.[0];
@@ -100,6 +124,12 @@ async function importConfigModule(file) {
100
124
  rmSync(tmp, { force: true });
101
125
  }
102
126
  }
127
+ var QUICKSTART_DEFAULT_FEATURES = ["cms", "notifications"];
128
+ function quickstartFeatures(explicit, cfg) {
129
+ if (explicit !== void 0) return explicit.split(",").map((s) => s.trim()).filter(Boolean);
130
+ const declared = Object.keys(cfg?.features ?? {});
131
+ return declared.length > 0 ? declared : [...QUICKSTART_DEFAULT_FEATURES];
132
+ }
103
133
  async function loadVxilConfig(cwd = process.cwd()) {
104
134
  const found = CONFIG_FILENAMES.map((n) => resolve(cwd, n)).find((f) => existsSync(f));
105
135
  if (!found) {
@@ -114,7 +144,7 @@ async function loadVxilConfig(cwd = process.cwd()) {
114
144
  const msg = e.message;
115
145
  let hint = "";
116
146
  if (/Cannot find (package|module) '?(vxil\/config|@vxil\/config)/.test(msg)) {
117
- hint = "\n \u2192 the config imports '@vxil/config' \u2014 install it: `npm i -D @vxil/config` (`vxil init`/`try`/`pull` scaffolds declare it in package.json \u2014 run your package manager's install; in the monorepo the workspace alias resolves it).";
147
+ hint = "\n \u2192 the config imports '@vxil/config' and this CLI install carries no copy to fall back to \u2014 install it: `npm i -D @vxil/config` (`vxil init`/`try`/`pull` scaffolds declare it in package.json; a complete @vxil/cli install resolves it from its own dist/config.js).";
118
148
  } else if (/Unknown file extension ".ts"|ERR_UNKNOWN_FILE_EXTENSION/.test(msg)) {
119
149
  hint = "\n \u2192 vxil.config.ts needs the tsx runtime; run via `npx vxil` (the bin uses tsx) or rename to .mjs.";
120
150
  } else if (/SyntaxError|is not defined|Unexpected/.test(msg)) {
@@ -3373,7 +3403,7 @@ ${fnBody}
3373
3403
  // Filter-operator aliases referenced by Filterable above. Ops mirror cms-v1
3374
3404
  // query.ts; multiple ops in one object AND-compose. Range ops ($ne/$gt/$gte/
3375
3405
  // $lt/$lte) require an index slot \u2014 the server leniently admits unslotted
3376
- // ranges via unindexed JSONB scans, deliberately NOT typed here (escape hatch:
3406
+ // ranges via unindexed scans, deliberately NOT typed here (escape hatch:
3377
3407
  // the untyped \`vx.cms.items.query\`). $in is typed on every slot family
3378
3408
  // (s*/n*/t*) \u2014 cms-v1 binds the bounded array with a per-family SQL cast.
3379
3409
  // Runtime caps not encodable in types:\u22648 filter terms, $in 1\u201350 elements, filter \u22642KB; $contains is an
@@ -3420,7 +3450,7 @@ var TOOLS = [
3420
3450
  inputSchema: {
3421
3451
  type: "object",
3422
3452
  properties: {
3423
- user_id: { type: "string", description: "Opaque end_user_id from tenant_users." },
3453
+ user_id: { type: "string", description: "Opaque end_user_id (your user id)." },
3424
3454
  template: { type: "string", enum: ["magic-link", "welcome", "transactional"] },
3425
3455
  data: { type: "object", additionalProperties: true },
3426
3456
  locale: {
@@ -3812,7 +3842,7 @@ var TOOLS = [
3812
3842
  inputSchema: {
3813
3843
  type: "object",
3814
3844
  properties: {
3815
- user_id: { type: "string", description: "Owning end_user_id from tenant_users." },
3845
+ user_id: { type: "string", description: "Owning end_user_id (your user id)." },
3816
3846
  filename: { type: "string" },
3817
3847
  content_type: { type: "string" },
3818
3848
  size_bytes: { type: "number" }
@@ -4662,7 +4692,7 @@ var TOOLS = [
4662
4692
  inputSchema: {
4663
4693
  type: "object",
4664
4694
  properties: {
4665
- user_id: { type: "string", description: "Opaque end_user_id from tenant_users." },
4695
+ user_id: { type: "string", description: "Opaque end_user_id (your user id)." },
4666
4696
  entitlement: { type: "string", description: "Entitlement key to check, e.g. 'export' or 'seats'." }
4667
4697
  },
4668
4698
  required: ["user_id", "entitlement"]
@@ -4678,7 +4708,7 @@ var TOOLS = [
4678
4708
  inputSchema: {
4679
4709
  type: "object",
4680
4710
  properties: {
4681
- user_id: { type: "string", description: "Opaque end_user_id from tenant_users." },
4711
+ user_id: { type: "string", description: "Opaque end_user_id (your user id)." },
4682
4712
  credit_type: { type: "string", description: "Credit type to read, e.g. 'scans'." }
4683
4713
  },
4684
4714
  required: ["user_id", "credit_type"]
@@ -4905,13 +4935,13 @@ function buildMcpCatalog(input, tools = TOOLS) {
4905
4935
 
4906
4936
  // src/store.ts
4907
4937
  import { homedir } from "node:os";
4908
- import { join, resolve as resolve3 } from "node:path";
4938
+ import { join as join2, resolve as resolve3 } from "node:path";
4909
4939
  import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, chmodSync } from "node:fs";
4910
4940
  function credDir() {
4911
- return join(homedir(), ".vxil");
4941
+ return join2(homedir(), ".vxil");
4912
4942
  }
4913
4943
  function credFile() {
4914
- return join(credDir(), "credentials.json");
4944
+ return join2(credDir(), "credentials.json");
4915
4945
  }
4916
4946
  function loadCredentials() {
4917
4947
  try {
@@ -7729,7 +7759,7 @@ var TEMPLATE_CATALOG = [
7729
7759
  "hasFunctions": false,
7730
7760
  "byoKeys": [],
7731
7761
  "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// \"Blog\" \u2014 a publishing / headless-CMS backend (authors, categories, posts with\n// a draft\u2192publish lifecycle), declared end-to-end in ONE typed file. A BLUEPRINT\n// composing shipped building blocks \u2014\n// \u2022 cms \u2192 authors \u2192 categories \u2192 posts (resolved by relation)\n// \u2022 comments \u2192 threaded reader comments on posts\n// Everything here is DATA the tenant owns and edits after `vxil init`. The\n// editorial workflow migrates ~100%; the public reader tier is SHIPPED \u2014 `posts`\n// is `public: true`, served keyless over the cms public-delivery lane (cms.md \xA716).\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, // the editorial lifecycle: write as draft, publish live\n hooks: {\n // Every post needs a title \u2014 a pure function of the row (Lane-A validate).\n post_title: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.title) > 0',\n message: 'a post needs a title',\n },\n },\n },\n comments: {}, // threaded reader comments on published posts\n notifications: { provider: 'mock', fromEmail: 'noreply@blog.app' },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n authors: {\n singular: 'author',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n bio: { type: 'text' },\n },\n },\n categories: {\n singular: 'category',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n },\n },\n posts: {\n singular: 'post',\n // PUBLIC DELIVERY (cms.md \xA716): published posts are readable with NO API\n // key over GET /v1/cms/public/:tenantId/posts \u2014 edge-cached, drafts never\n // served. This is the reader tier of a blog: a static/JAMstack front-end\n // (or the served `listCmsPublic()` SDK helper) fetches the feed anonymously.\n public: true,\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', required: true, indexSlot: 's2', unique: true },\n excerpt: { type: 'text' },\n body: { type: 'text' },\n author: { type: 'relation', relationTo: 'authors', indexSlot: 's3' },\n category: { type: 'relation', relationTo: 'categories', indexSlot: 's4' },\n published_at: { type: 'datetime', indexSlot: 't1' },\n reading_minutes: { type: 'int', indexSlot: 'n1' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'authors',\n items: [{ name: 'Ada Lovelace', slug: 'ada', bio: 'Writes about computing.' }],\n },\n ],\n },\n});\n",
7732
- "readme": "# Blog / Publishing template\n\nA publishing / headless-CMS backend \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `authors` \u2014 name, unique slug, bio.\n- `categories` \u2014 name, unique slug.\n- `posts` \u2014 title, unique slug, excerpt, body, `author` + `category` relations (slot-bound for filtering),\n `published_at`, `reading_minutes`. A Lane-A hook requires a title, and `draftPublish` gives you the\n write-as-draft \u2192 publish-live editorial lifecycle. **`public: true`** \u2014 published posts are served over\n the keyless public-delivery lane (see below).\n- `comments` \u2014 threaded reader comments on published posts.\n\n**Use it:**\n\n```bash\nvxil init --template blog\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The editorial lifecycle is config** \u2014 `draftPublish: true` gives write-as-draft \u2192 `publish`; readers filter\n with the reserved `$status` key (`docs/features/cms.md` \xA73).\n- **Slot-bound relations are the join declaration** \u2014 `posts.author`/`posts.category` ride `s3`/`s4`, so a feed\n filters by author directly, or reaches one hop into the target: `?filter={\"category.slug\":\"news\"}` is the\n \xA712.1 single-hop dotted-key join.\n- **Invariants ride as tenant-owned Lane-A hooks** \u2014 the \"posts need a title\" rule is data in YOUR config, not\n platform code (\xA77).\n\n```ts\n// the reader feed with a server key: published posts, newest first\nconst { items, next_cursor } = await vx.from('posts').query({\n filter: { $status: 'published' }, sort: '-published_at', limit: 25,\n});\n```\n\n**The public reader tier \u2014 keyless** (`docs/features/cms.md` \xA716). Because `posts` is `public: true`, a\nstatic/JAMstack front-end reads the feed with **no API key**: the edge mints a restricted read-only token,\nforces `status = 'published'` (drafts are never served), and edge-caches the response\n(`s-maxage=60`, `stale-while-revalidate`).\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public feed \u2014 NO api key, no Vxil client, no auth\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'posts', { sort: '-published_at', limit: 25 });\n// one post by slug (a draft slug 404s to the reader)\nconst { items: [post] } = await listCmsPublic('ten_your_tenant_id', 'posts', { filter: { slug: 'hello' }, limit: 1 });\n```\n\n**Reader comments UI (optional):** drop [`@vxil/react/comments`](../../packages/react) onto a post\npage for the threaded `comments` feature \u2014 a pure client-side component over the shipped comments API.\n\n**Go deeper:** `docs/features/cms.md` (\xA73 query DSL \xB7 \xA77 hooks \xB7 \xA712.1 joins \xB7 **\xA716 public delivery**) \xB7\n`docs/features/comments.md` \xB7 `templates/docs-site/` (a pure public-content site) \xB7\n`templates/catalog/` (the same shape for products) \xB7 `examples/feedback-board/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
7762
+ "readme": "# Blog / Publishing template\n\nA publishing / headless-CMS backend \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `authors` \u2014 name, unique slug, bio.\n- `categories` \u2014 name, unique slug.\n- `posts` \u2014 title, unique slug, excerpt, body, `author` + `category` relations (slot-bound for filtering),\n `published_at`, `reading_minutes`. A Lane-A hook requires a title, and `draftPublish` gives you the\n write-as-draft \u2192 publish-live editorial lifecycle. **`public: true`** \u2014 published posts are served over\n the keyless public-delivery lane (see below).\n- `comments` \u2014 threaded reader comments on published posts.\n\n**Use it:**\n\n```bash\nvxil init --template blog # (the CLI resolves @vxil/config itself \u2014 no install needed for the push)\nvxil quickstart --invite <code> # only when the email is new\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The editorial lifecycle is config** \u2014 `draftPublish: true` gives write-as-draft \u2192 `publish`; readers filter\n with the reserved `$status` key (vxil.com/docs/guide/04-data-with-cms).\n- **Slot-bound relations are the join declaration** \u2014 `posts.author`/`posts.category` ride `s3`/`s4`, so a feed\n filters by author directly, or reaches one hop into the target: `?filter={\"category.slug\":\"news\"}` is the\n \xA712.1 single-hop dotted-key join.\n- **Invariants ride as tenant-owned Lane-A hooks** \u2014 the \"posts need a title\" rule is data in YOUR config, not\n platform code (\xA77).\n\n```ts\n// the reader feed with a server key: published posts, newest first\nconst { items, next_cursor } = await vx.from('posts').query({\n filter: { $status: 'published' }, sort: '-published_at', limit: 25,\n});\n```\n\n**The public reader tier \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `posts` is `public: true`, a\nstatic/JAMstack front-end reads the feed with **no API key**: the edge mints a restricted read-only token,\nforces `status = 'published'` (drafts are never served), and edge-caches the response\n(`s-maxage=60`, `stale-while-revalidate`).\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public feed \u2014 NO api key, no Vxil client, no auth\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'posts', { sort: '-published_at', limit: 25 });\n// one post by slug (a draft slug 404s to the reader)\nconst { items: [post] } = await listCmsPublic('ten_your_tenant_id', 'posts', { filter: { slug: 'hello' }, limit: 1 });\n```\n\n**Reader comments UI (optional):** drop [`@vxil/react/comments`](../../packages/react) onto a post\npage for the threaded `comments` feature \u2014 a pure client-side component over the shipped comments API.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 joins \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/06-feature-catalog (comments) \xB7 `templates/docs-site/` (a pure public-content site) \xB7\n`templates/catalog/` (the same shape for products) \xB7 `examples/feedback-board/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
7733
7763
  "functions": {}
7734
7764
  },
7735
7765
  {
@@ -7750,8 +7780,8 @@ var TEMPLATE_CATALOG = [
7750
7780
  ],
7751
7781
  "hasFunctions": false,
7752
7782
  "byoKeys": [],
7753
- "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// \"Microblog\" \u2014 a Twitter-style micro-blogging backend (profiles, 280-char\n// posts, follows, likes, a realtime live feed), declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 profiles \u2192 posts (by relation) + follows/likes edge rows\n// \u2022 auth \u2192 accounts, so a poster is a VERIFIED end-user\n// \u2022 realtime \u2192 the live feed channel (posts fan out via the cms `cdc` bridge)\n// Every collection declares an end-user OWNER field, so from a thin client a\n// signed-in user can only write their OWN rows; tenant-wide reads (the global\n// timeline, follower counts) are served by YOUR backend with a server key \u2014\n// owner-scoping is a no-op for server callers (docs/features/cms.md \xA715).\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 draftPublish: true, // create posts with `status: 'published'` \u2014 no editorial step\n // Fail-safe (cms.md \xA715.1): a verified end-user key may only touch\n // collections that declare an ownerField. Every collection below does;\n // any collection you ADD later without one is denied to end-user keys\n // instead of silently shared tenant-wide. Server keys are unaffected.\n strictEndUserScope: true,\n // Lane-A safe-expression hooks \u2014 AST-validated at push time, run inside\n // the write transaction (cms.md \xA77). NOTE: hooks do NOT run on `$inc`\n // (\xA79.3) \u2014 likes_count is guarded by its own validation.min instead.\n hooks: {\n post_body_nonempty: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.body)) > 0',\n message: 'a post cannot be empty',\n },\n post_body_280: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.body) <= 280',\n message: 'a post is at most 280 characters',\n },\n // Field-vs-field comparison is grammar-legal (\xA77.2 operators over item.*).\n follow_not_self: {\n collection: 'follows',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'item.follower != item.followee',\n message: 'you cannot follow yourself',\n },\n // COMPOSED-KEY integrity: `pair` is written by the client/SDK as\n // follower + ':' + followee (see the field comment). This hook makes\n // that convention server-enforced, so the `unique: true` claim on\n // `pair` really means \"at most one follow edge per (follower,\n // followee)\" \u2014 a double-follow is a clean 409 unique_violation.\n follow_pair: {\n collection: 'follows',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.pair == concat(item.follower, ':', item.followee)\",\n message: \"pair must be follower + ':' + followee\",\n },\n like_pair: {\n collection: 'likes',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.pair == concat(item.post, ':', item.user_id)\",\n message: \"pair must be post + ':' + user_id\",\n },\n },\n // Realtime CDC bridge (cms.md \xA714): every NEW post auto-publishes a\n // `cms.item.created` / `.published` frame (this rule's two events \u2014\n // updates/deletes don't fire it) \u2014 full item data, \u226432KB \u2014 onto the\n // realtime channel below. The config-only live feed: at-most-once,\n // fire-and-forget (guaranteed delivery would use webhooks or functions).\n cdc: {\n feed_live: {\n collection: 'posts',\n channel: 'feed:global',\n events: ['created', 'published'],\n payload: 'full',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // posters sign in as end-users\n realtime: {}, // defaults are fine; a channel exists as soon as someone uses it\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 profiles: {\n singular: 'profile',\n // End-user owner-scope (cms.md \xA715): a signed-in user edits only their\n // OWN profile. `user_id` must be a real string field (declared below).\n ownerField: 'user_id',\n // PUBLIC DELIVERY (cms.md \xA716): public profile pages read with NO API key\n // over GET /v1/cms/public/:tenantId/profiles \u2014 edge-cached, and the\n // `user_id` owner field is STRIPPED from every served row (an anonymous\n // reader never sees the end-user id). Owner-scoping (above) still governs\n // the authed WRITE lane; public delivery is a read-only, owner-unscoped tier.\n public: true,\n fields: {\n // Declarative uniqueness (cms.md \xA79.3), carried by `vxil push`: N\n // racing claims of a handle yield exactly one 201, the rest a clean\n // 409 unique_violation \u2014 the insert IS the claim. (Flipping the flag\n // on an ALREADY-pushed field is a dashboard/REST in-place alter \u2014\n // push's diff compares field name+type only.)\n handle: { type: 'string', required: true, indexSlot: 's1', unique: true },\n display_name: { type: 'string', indexSlot: 's2' },\n bio: { type: 'text' },\n user_id: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n },\n },\n posts: {\n singular: 'post',\n ownerField: 'user_id',\n // PUBLIC DELIVERY (cms.md \xA716): the GLOBAL TIMELINE served with NO API key\n // over GET /v1/cms/public/:tenantId/posts?sort=-posted_at \u2014 edge-cached,\n // published-only, `user_id` stripped from every row. This is exactly the\n // \"tenant-wide reads served by YOUR backend\" note above, but now keyless:\n // an anonymous visitor reads the public feed without your server key.\n public: true,\n fields: {\n body: { type: 'text', required: true }, // \u2264280 chars \u2014 enforced by the hooks above\n author: { type: 'relation', relationTo: 'profiles', indexSlot: 's1' },\n user_id: { type: 'string', indexSlot: 's2' }, // the owner (end-user) id\n posted_at: { type: 'datetime', indexSlot: 't1' }, // slot t1 \u21D2 sort=-posted_at is index-served\n // Bumped atomically via PATCH {\"$inc\":{\"likes_count\":1}} (cms.md \xA79.3);\n // min:0 turns a decrement below zero into a clean 409, never a race.\n likes_count: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n },\n },\n follows: {\n singular: 'follow',\n // Owner = the follower: an end-user creates/removes only their OWN edges.\n ownerField: 'follower',\n fields: {\n follower: { type: 'string', required: true, indexSlot: 's1' }, // end-user id\n followee: { type: 'string', required: true, indexSlot: 's2' }, // end-user id\n // COMPOSED KEY \u2014 cms `unique` is single-field, so composite uniqueness\n // is modeled by having the client/SDK write follower + ':' + followee\n // here; the `follow_pair` hook rejects a mismatched composition, and\n // `unique: true` (cms.md \xA79.3) makes a double-follow a clean 409\n // unique_violation on a plain create \u2014 no lock/guard needed for\n // pair dedup. (lock+guard, \xA710, stays the tool for count-invariants\n // BEYOND uniqueness \u2014 e.g. \"at most N\".)\n pair: { type: 'string', required: true, indexSlot: 's3', unique: true },\n },\n },\n likes: {\n singular: 'like',\n ownerField: 'user_id',\n fields: {\n post: { type: 'relation', relationTo: 'posts', required: true, indexSlot: 's1' },\n user_id: { type: 'string', required: true, indexSlot: 's2' }, // the owner (end-user) id\n // COMPOSED KEY \u2014 post + ':' + user_id, declared unique: one like per\n // user per post; a double-like is a clean 409 unique_violation.\n pair: { type: 'string', required: true, indexSlot: 's3', unique: true },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'profiles',\n items: [\n { handle: 'ada', display_name: 'Ada Lovelace', bio: 'Notes on engines, in 280 chars.', user_id: 'usr_demo_ada' },\n { handle: 'grace', display_name: 'Grace Hopper', bio: 'Compilers, ships, short posts.', user_id: 'usr_demo_grace' },\n ],\n },\n {\n // The `author` relation is omitted here: seed items are plain creates and\n // cannot reference the server-generated item_id of the profiles above \u2014\n // set it on posts your app creates at runtime. Seeded items land as\n // drafts; publish them from the dashboard, or create real posts with\n // `status: 'published'` (see README).\n collection: 'posts',\n items: [\n { body: 'Hello, world \u2014 first post on my own backend.', user_id: 'usr_demo_ada', posted_at: '2026-07-01T09:00:00Z', likes_count: 0 },\n { body: 'A microblog is just cms + auth + realtime in one config file.', user_id: 'usr_demo_grace', posted_at: '2026-07-01T09:05:00Z', likes_count: 0 },\n ],\n },\n ],\n },\n});\n",
7754
- "readme": "# Microblog / Social Feed template\n\nA Twitter-style micro-blogging backend \u2014 profiles, 280-character posts, follows, likes, and a realtime\nlive feed \u2014 declared end-to-end in one typed `vxil.config.ts`. Backend building blocks you enable in one\nline: `cms` holds the data (Lane-A hooks enforce the 280-char rule inside the write transaction), `auth`\nmakes every poster a verified end-user, and `realtime` streams new posts to every open client.\n\n**What it provisions:**\n- `profiles` \u2014 unique `handle`, display name, bio; owner-scoped by `user_id`. **`public: true`** \u2014 public\n profile pages read keyless (the `user_id` owner field is stripped from every served row).\n- `posts` \u2014 `body` (two validate hooks: non-empty, \u2264280), `author` relation \u2192 profiles, `posted_at`,\n atomic `likes_count`; owner-scoped by `user_id`. **`public: true`** \u2014 the global timeline reads keyless.\n- `follows` \u2014 `follower`/`followee` + a composed unique `pair` key; hooks block self-follows and a malformed pair.\n- `likes` \u2014 `post` relation + `user_id` + a composed unique `pair` key (one like per user per post).\n- Features: `cms` (with `strictEndUserScope` + a `cdc` live-feed rule), `auth` (email/password), `realtime`.\n\n**Apply it:**\n\n```bash\nvxil init --template microblog\nvxil quickstart # or `vxil link` to an existing tenant\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n1. **End-user owner scoping** (`docs/features/cms.md` \xA715) \u2014 every collection names an `ownerField`, so a\n signed-in user can only write their OWN rows from a thin client; `strictEndUserScope: true` fail-closes\n any collection you later forget to scope. Server keys are unaffected \u2014 the global timeline and\n follower counts are served by your backend, where owner-scoping is a no-op.\n2. **Composed-key uniqueness** \u2014 cms `unique` is single-field, so \"unique (follower, followee)\" is modeled\n as a `pair` field the client writes as `follower + ':' + followee`; a validate hook enforces the\n composition, and the declarative `unique: true` on `pair` (in the config, carried by `vxil push` \u2014\n `cms.md` \xA79.3) turns a double-follow/double-like into a clean `409 unique_violation` on a plain\n create \u2014 no lock needed. (`lock` + `guard` (`cms.md` \xA710) remains the general tool for\n count-invariants BEYOND uniqueness, e.g. \"at most N seats/redemptions\".)\n3. **Atomic counters** (`cms.md` \xA79.3) \u2014 `likes_count` bumps via `$inc`: ONE conditional UPDATE, no\n read-modify-write race; `validation.min: 0` refuses a decrement below zero.\n4. **Realtime live feed** (`cms.md` \xA714) \u2014 the `cdc` rule auto-publishes every new post (its\n `created`/`published` events) onto the `feed:global` channel; browsers subscribe with\n `@vxil/realtime` (`docs/features/realtime.md` \xA75).\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nimport type { VxilSchema } from './vxil.types';\nconst vx = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! });\n\n// post \u2014 the hooks reject empty or >280 bodies inside the write tx\nconst { item_id: post } = await vx.from('posts').create(\n { body: 'hello from my own backend', user_id: 'usr_demo_ada',\n posted_at: new Date().toISOString(), likes_count: 0 },\n { status: 'published' }, // also fires the cdc frame onto feed:global\n);\n// like it \u2014 a plain create: the unique claim on `pair` makes a double-like a clean 409 unique_violation \u2026\nconst pair = `${post}:usr_demo_grace`;\nawait vx.from('likes').create({ post, user_id: 'usr_demo_grace', pair });\n// \u2026 and the counter bumps atomically, no read-modify-write (cms.md \xA79.3)\nawait vx.from('posts').inc(post, { likes_count: 1 });\n```\n\n**The public timeline \u2014 keyless** (`docs/features/cms.md` \xA716). Because `posts` and `profiles` are\n`public: true`, an anonymous visitor reads the global feed and public profile pages with **no API key** \u2014\nthe edge forces `status = 'published'`, edge-caches the page, and **strips the `user_id` owner field** from\nevery row (an anonymous reader never sees an end-user id). Owner-scoping still governs every authed WRITE:\nthe two lanes are independent. (`follows`/`likes` stay private \u2014 no `public` flag.)\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public global timeline \u2014 NO api key, user_id stripped from every row\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'posts', { sort: '-posted_at', limit: 25 });\n// a public profile by handle\nconst { items: [p] } = await listCmsPublic('ten_your_tenant_id', 'profiles', { filter: { handle: 'ada' }, limit: 1 });\n```\n\n**Drop-in UI (optional):** [`@vxil/realtime`](../../packages/realtime) is the browser client for the live\n`feed:global` channel; [`@vxil/react/feed`](../../packages/react) is a React timeline +\nnotification-bell surface over the shipped `activity-feed` API for a follow-graph home feed. (The React\ncomponents ship via npm into a bundled app \u2014 there is no served `.mjs` for them, unlike `@vxil/realtime`.)\n\n**Go deeper:** `docs/features/cms.md` (\xA77 hooks, \xA79 concurrency, \xA710 lock/guard, \xA714 CDC, \xA715 owner-scoping,\n**\xA716 public delivery**), `docs/features/realtime.md` (+ the `@vxil/realtime` browser client),\n`docs/features/auth.md`, and `examples/ecommerce/` for the same patterns composed with functions.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
7783
+ "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// \"Microblog\" \u2014 a Twitter-style micro-blogging backend (profiles, 280-char\n// posts, follows, likes, a realtime live feed), declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 profiles \u2192 posts (by relation) + follows/likes edge rows\n// \u2022 auth \u2192 accounts, so a poster is a VERIFIED end-user\n// \u2022 realtime \u2192 the live feed channel (posts fan out via the cms `cdc` bridge)\n// Every collection declares an end-user OWNER field, so from a thin client a\n// signed-in user can only write their OWN rows; tenant-wide reads (the global\n// timeline, follower counts) are served by YOUR backend with a server key \u2014\n// owner-scoping is a no-op for server callers (vxil.com/docs/guide/04-data-with-cms).\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 draftPublish: true, // create posts with `status: 'published'` \u2014 no editorial step\n // Fail-safe (cms.md \xA715.1): a verified end-user key may only touch\n // collections that declare an ownerField. Every collection below does;\n // any collection you ADD later without one is denied to end-user keys\n // instead of silently shared tenant-wide. Server keys are unaffected.\n strictEndUserScope: true,\n // Lane-A safe-expression hooks \u2014 AST-validated at push time, run inside\n // the write transaction (cms.md \xA77). NOTE: hooks do NOT run on `$inc`\n // (\xA79.3) \u2014 likes_count is guarded by its own validation.min instead.\n hooks: {\n post_body_nonempty: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.body)) > 0',\n message: 'a post cannot be empty',\n },\n post_body_280: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.body) <= 280',\n message: 'a post is at most 280 characters',\n },\n // Field-vs-field comparison is grammar-legal (\xA77.2 operators over item.*).\n follow_not_self: {\n collection: 'follows',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'item.follower != item.followee',\n message: 'you cannot follow yourself',\n },\n // COMPOSED-KEY integrity: `pair` is written by the client/SDK as\n // follower + ':' + followee (see the field comment). This hook makes\n // that convention server-enforced, so the `unique: true` claim on\n // `pair` really means \"at most one follow edge per (follower,\n // followee)\" \u2014 a double-follow is a clean 409 unique_violation.\n follow_pair: {\n collection: 'follows',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.pair == concat(item.follower, ':', item.followee)\",\n message: \"pair must be follower + ':' + followee\",\n },\n like_pair: {\n collection: 'likes',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.pair == concat(item.post, ':', item.user_id)\",\n message: \"pair must be post + ':' + user_id\",\n },\n },\n // Realtime CDC bridge (cms.md \xA714): every NEW post auto-publishes a\n // `cms.item.created` / `.published` frame (this rule's two events \u2014\n // updates/deletes don't fire it) \u2014 full item data, \u226432KB \u2014 onto the\n // realtime channel below. The config-only live feed: at-most-once,\n // fire-and-forget (guaranteed delivery would use webhooks or functions).\n cdc: {\n feed_live: {\n collection: 'posts',\n channel: 'feed:global',\n events: ['created', 'published'],\n payload: 'full',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // posters sign in as end-users\n realtime: {}, // defaults are fine; a channel exists as soon as someone uses it\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 profiles: {\n singular: 'profile',\n // End-user owner-scope (cms.md \xA715): a signed-in user edits only their\n // OWN profile. `user_id` must be a real string field (declared below).\n ownerField: 'user_id',\n // PUBLIC DELIVERY (cms.md \xA716): public profile pages read with NO API key\n // over GET /v1/cms/public/:tenantId/profiles \u2014 edge-cached, and the\n // `user_id` owner field is STRIPPED from every served row (an anonymous\n // reader never sees the end-user id). Owner-scoping (above) still governs\n // the authed WRITE lane; public delivery is a read-only, owner-unscoped tier.\n public: true,\n fields: {\n // Declarative uniqueness (cms.md \xA79.3), carried by `vxil push`: N\n // racing claims of a handle yield exactly one 201, the rest a clean\n // 409 unique_violation \u2014 the insert IS the claim. (Flipping the flag\n // on an ALREADY-pushed field is a dashboard/REST in-place alter \u2014\n // push's diff compares field name+type only.)\n handle: { type: 'string', required: true, indexSlot: 's1', unique: true },\n display_name: { type: 'string', indexSlot: 's2' },\n bio: { type: 'text' },\n user_id: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n },\n },\n posts: {\n singular: 'post',\n ownerField: 'user_id',\n // PUBLIC DELIVERY (cms.md \xA716): the GLOBAL TIMELINE served with NO API key\n // over GET /v1/cms/public/:tenantId/posts?sort=-posted_at \u2014 edge-cached,\n // published-only, `user_id` stripped from every row. This is exactly the\n // \"tenant-wide reads served by YOUR backend\" note above, but now keyless:\n // an anonymous visitor reads the public feed without your server key.\n public: true,\n fields: {\n body: { type: 'text', required: true }, // \u2264280 chars \u2014 enforced by the hooks above\n author: { type: 'relation', relationTo: 'profiles', indexSlot: 's1' },\n user_id: { type: 'string', indexSlot: 's2' }, // the owner (end-user) id\n posted_at: { type: 'datetime', indexSlot: 't1' }, // slot t1 \u21D2 sort=-posted_at is index-served\n // Bumped atomically via PATCH {\"$inc\":{\"likes_count\":1}} (cms.md \xA79.3);\n // min:0 turns a decrement below zero into a clean 409, never a race.\n likes_count: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n },\n },\n follows: {\n singular: 'follow',\n // Owner = the follower: an end-user creates/removes only their OWN edges.\n ownerField: 'follower',\n fields: {\n follower: { type: 'string', required: true, indexSlot: 's1' }, // end-user id\n followee: { type: 'string', required: true, indexSlot: 's2' }, // end-user id\n // COMPOSED KEY \u2014 cms `unique` is single-field, so composite uniqueness\n // is modeled by having the client/SDK write follower + ':' + followee\n // here; the `follow_pair` hook rejects a mismatched composition, and\n // `unique: true` (cms.md \xA79.3) makes a double-follow a clean 409\n // unique_violation on a plain create \u2014 no lock/guard needed for\n // pair dedup. (lock+guard, \xA710, stays the tool for count-invariants\n // BEYOND uniqueness \u2014 e.g. \"at most N\".)\n pair: { type: 'string', required: true, indexSlot: 's3', unique: true },\n },\n },\n likes: {\n singular: 'like',\n ownerField: 'user_id',\n fields: {\n post: { type: 'relation', relationTo: 'posts', required: true, indexSlot: 's1' },\n user_id: { type: 'string', required: true, indexSlot: 's2' }, // the owner (end-user) id\n // COMPOSED KEY \u2014 post + ':' + user_id, declared unique: one like per\n // user per post; a double-like is a clean 409 unique_violation.\n pair: { type: 'string', required: true, indexSlot: 's3', unique: true },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'profiles',\n items: [\n { handle: 'ada', display_name: 'Ada Lovelace', bio: 'Notes on engines, in 280 chars.', user_id: 'usr_demo_ada' },\n { handle: 'grace', display_name: 'Grace Hopper', bio: 'Compilers, ships, short posts.', user_id: 'usr_demo_grace' },\n ],\n },\n {\n // The `author` relation is omitted here: seed items are plain creates and\n // cannot reference the server-generated item_id of the profiles above \u2014\n // set it on posts your app creates at runtime. Seeded items land as\n // drafts; publish them from the dashboard, or create real posts with\n // `status: 'published'` (see README).\n collection: 'posts',\n items: [\n { body: 'Hello, world \u2014 first post on my own backend.', user_id: 'usr_demo_ada', posted_at: '2026-07-01T09:00:00Z', likes_count: 0 },\n { body: 'A microblog is just cms + auth + realtime in one config file.', user_id: 'usr_demo_grace', posted_at: '2026-07-01T09:05:00Z', likes_count: 0 },\n ],\n },\n ],\n },\n});\n",
7784
+ "readme": "# Microblog / Social Feed template\n\nA Twitter-style micro-blogging backend \u2014 profiles, 280-character posts, follows, likes, and a realtime\nlive feed \u2014 declared end-to-end in one typed `vxil.config.ts`. Backend building blocks you enable in one\nline: `cms` holds the data (Lane-A hooks enforce the 280-char rule inside the write transaction), `auth`\nmakes every poster a verified end-user, and `realtime` streams new posts to every open client.\n\n**What it provisions:**\n- `profiles` \u2014 unique `handle`, display name, bio; owner-scoped by `user_id`. **`public: true`** \u2014 public\n profile pages read keyless (the `user_id` owner field is stripped from every served row).\n- `posts` \u2014 `body` (two validate hooks: non-empty, \u2264280), `author` relation \u2192 profiles, `posted_at`,\n atomic `likes_count`; owner-scoped by `user_id`. **`public: true`** \u2014 the global timeline reads keyless.\n- `follows` \u2014 `follower`/`followee` + a composed unique `pair` key; hooks block self-follows and a malformed pair.\n- `likes` \u2014 `post` relation + `user_id` + a composed unique `pair` key (one like per user per post).\n- Features: `cms` (with `strictEndUserScope` + a `cdc` live-feed rule), `auth` (email/password), `realtime`.\n\n**Apply it:**\n\n```bash\nvxil init --template microblog\nvxil quickstart # or `vxil link` to an existing tenant\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n1. **End-user owner scoping** (vxil.com/docs/guide/04-data-with-cms) \u2014 every collection names an `ownerField`, so a\n signed-in user can only write their OWN rows from a thin client; `strictEndUserScope: true` fail-closes\n any collection you later forget to scope. Server keys are unaffected \u2014 the global timeline and\n follower counts are served by your backend, where owner-scoping is a no-op.\n2. **Composed-key uniqueness** \u2014 cms `unique` is single-field, so \"unique (follower, followee)\" is modeled\n as a `pair` field the client writes as `follower + ':' + followee`; a validate hook enforces the\n composition, and the declarative `unique: true` on `pair` (in the config, carried by `vxil push` \u2014\n `cms.md` \xA79.3) turns a double-follow/double-like into a clean `409 unique_violation` on a plain\n create \u2014 no lock needed. (`lock` + `guard` (`cms.md` \xA710) remains the general tool for\n count-invariants BEYOND uniqueness, e.g. \"at most N seats/redemptions\".)\n3. **Atomic counters** (`cms.md` \xA79.3) \u2014 `likes_count` bumps via `$inc`: ONE conditional UPDATE, no\n read-modify-write race; `validation.min: 0` refuses a decrement below zero.\n4. **Realtime live feed** (`cms.md` \xA714) \u2014 the `cdc` rule auto-publishes every new post (its\n `created`/`published` events) onto the `feed:global` channel; browsers subscribe with\n `@vxil/realtime` (vxil.com/docs/guide/06-feature-catalog: realtime).\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nimport type { VxilSchema } from './vxil.types';\nconst vx = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! });\n\n// post \u2014 the hooks reject empty or >280 bodies inside the write tx\nconst { item_id: post } = await vx.from('posts').create(\n { body: 'hello from my own backend', user_id: 'usr_demo_ada',\n posted_at: new Date().toISOString(), likes_count: 0 },\n { status: 'published' }, // also fires the cdc frame onto feed:global\n);\n// like it \u2014 a plain create: the unique claim on `pair` makes a double-like a clean 409 unique_violation \u2026\nconst pair = `${post}:usr_demo_grace`;\nawait vx.from('likes').create({ post, user_id: 'usr_demo_grace', pair });\n// \u2026 and the counter bumps atomically, no read-modify-write (cms.md \xA79.3)\nawait vx.from('posts').inc(post, { likes_count: 1 });\n```\n\n**The public timeline \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `posts` and `profiles` are\n`public: true`, an anonymous visitor reads the global feed and public profile pages with **no API key** \u2014\nthe edge forces `status = 'published'`, edge-caches the page, and **strips the `user_id` owner field** from\nevery row (an anonymous reader never sees an end-user id). Owner-scoping still governs every authed WRITE:\nthe two lanes are independent. (`follows`/`likes` stay private \u2014 no `public` flag.)\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public global timeline \u2014 NO api key, user_id stripped from every row\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'posts', { sort: '-posted_at', limit: 25 });\n// a public profile by handle\nconst { items: [p] } = await listCmsPublic('ten_your_tenant_id', 'profiles', { filter: { handle: 'ada' }, limit: 1 });\n```\n\n**Drop-in UI (optional):** [`@vxil/realtime`](../../packages/realtime) is the browser client for the live\n`feed:global` channel; [`@vxil/react/feed`](../../packages/react) is a React timeline +\nnotification-bell surface over the shipped `activity-feed` API for a follow-graph home feed. (The React\ncomponents ship via npm into a bundled app \u2014 there is no served `.mjs` for them, unlike `@vxil/realtime`.)\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (owner-scoping, **public delivery**), vxil.com/docs/guide/07-validation-and-hooks,\nvxil.com/docs/api (lock/guard on the item write routes), vxil.com/docs/guide/06-feature-catalog (realtime + the `@vxil/realtime` browser client,\nauth), and `examples/ecommerce/` for the same patterns composed with functions.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
7755
7785
  "functions": {}
7756
7786
  },
7757
7787
  {
@@ -7771,8 +7801,32 @@ var TEMPLATE_CATALOG = [
7771
7801
  ],
7772
7802
  "hasFunctions": false,
7773
7803
  "byoKeys": [],
7774
- "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// \"Community\" \u2014 a forum backend (threads \u2192 replies, one-vote-per-user voting,\n// live updates), declared end-to-end in ONE typed file. A BLUEPRINT composing\n// shipped building blocks \u2014\n// \u2022 cms \u2192 threads \u2192 replies (resolved by relation) + votes\n// \u2022 auth \u2192 accounts, so an author is a verified end-user\n// \u2022 realtime \u2192 live thread updates (wired config-only via the cms CDC bridge)\n// The load-bearing tricks: `ownerField: 'author'` (a member edits only their\n// OWN posts), `$inc` on `replies_count` (atomic counter, no read-modify-write),\n// and the COMPOSED-KEY vote \u2014 `pair` = voter + ':' + thread, derived by a hook\n// and declared `unique: true` (carried by `vxil push`, cms.md \xA79.3), so N\n// racing votes yield exactly one 201. Everything here is DATA the tenant owns\n// 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 // Posts go live when created with `status: 'published'` (the README\n // curls do); keep drafts as a moderation hold state if you want a\n // review queue. (`vxil seed` sends no status, so seed items land as drafts.)\n draftPublish: true,\n hooks: {\n // A thread needs a real title \u2014 whitespace-only is rejected (Lane-A).\n thread_title: {\n collection: 'threads',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.title)) > 0',\n message: 'a thread needs a title',\n },\n // A reply needs a non-empty body.\n reply_body: {\n collection: 'replies',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.body)) > 0',\n message: 'a reply needs a body',\n },\n // ONE VOTE PER USER, half 1: both inputs must be present\u2026\n vote_inputs: {\n collection: 'votes',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.voter)) > 0 && len(trim(item.thread)) > 0',\n message: 'a vote needs a voter and a thread',\n },\n // \u2026half 2: derive the composed key `pair` = voter + ':' + thread\n // SERVER-SIDE (a client can never mis-compose it). Half 3 is the\n // `unique: true` claim on votes.pair below \u2014 the insert IS the guard.\n vote_pair: {\n collection: 'votes',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'pair',\n expr: \"concat(item.voter, ':', item.thread)\",\n },\n },\n // Realtime CDC bridge (docs/features/cms.md \xA714): thread + reply writes\n // auto-publish `cms.item.created/updated/published` frames onto the\n // 'threads:live' realtime channel \u2014 config-only live updates. At-most-once\n // (a live-view convenience; guaranteed delivery stays webhooks/functions).\n cdc: {\n threads_live: {\n collection: 'threads',\n channel: 'threads:live',\n events: ['created', 'updated', 'published'],\n payload: 'ids',\n },\n replies_live: {\n collection: 'replies',\n channel: 'threads:live',\n events: ['created', 'published'],\n payload: 'ids',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // members sign in as end-users\n realtime: {}, // defaults are fine \u2014 channels for the CDC frames above\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 threads: {\n singular: 'thread',\n // End-user owner-scope (docs/features/cms.md \xA715): a verified member\n // may only edit their OWN threads. Server callers are unaffected.\n ownerField: 'author',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n // Declarative uniqueness (cms.md \xA79.3), carried by `vxil push`: a\n // duplicate slug is a clean 409 unique_violation. (Flipping the flag\n // on an ALREADY-pushed field is a dashboard/REST in-place alter \u2014\n // push's diff compares field name+type only.)\n slug: { type: 'string', indexSlot: 's2', unique: true },\n author: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n category: { type: 'string', indexSlot: 's4' },\n // validation.min: 0 makes `$inc: { replies_count: -1 }` a conditional\n // decrement \u2014 the counter can never go negative under races.\n replies_count: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_activity: { type: 'datetime', indexSlot: 't1' }, // \"hot threads\" sort key\n body: { type: 'text' },\n },\n },\n replies: {\n singular: 'reply',\n ownerField: 'author',\n fields: {\n // Slot-bound relation = the JOIN declaration: dotted filter keys like\n // {\"thread.category\": \"announcements\"} reach the parent thread\n // (docs/features/cms.md \xA712.1).\n thread: { type: 'relation', relationTo: 'threads', indexSlot: 's1' },\n author: { type: 'string', indexSlot: 's2' },\n body: { type: 'text', required: true },\n posted_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n votes: {\n singular: 'vote',\n fields: {\n thread: { type: 'relation', relationTo: 'threads', indexSlot: 's1' },\n // The voting member's id \u2014 asserted by YOUR backend (server key) in\n // this blueprint's flow. For direct end-user voting, declare\n // `ownerField: 'voter'` so the verified session id is enforced\n // (cms.md \xA715) \u2014 at the cost of end-user keys then listing/counting\n // only their OWN votes.\n voter: { type: 'string', indexSlot: 's2' },\n // THE COMPOSED KEY \u2014 'voter:thread', derived by the vote_pair hook\n // above and declared unique: one vote per member per thread. N\n // racing votes \u2192 exactly one 201, the rest 409 unique_violation \u2014\n // no lock, no read-check-write.\n pair: { type: 'string', indexSlot: 's3', unique: true },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'threads',\n items: [\n {\n title: 'Welcome to the community',\n slug: 'welcome',\n author: 'admin',\n category: 'announcements',\n replies_count: 0,\n last_activity: '2026-01-01T00:00:00Z',\n body: 'Introduce yourself below.',\n },\n ],\n },\n {\n // Seed items are POSTed verbatim (no cross-item ref resolution), so this\n // reply carries no `thread` id \u2014 attach replies at runtime with the real\n // item_id (see the README snippet).\n collection: 'replies',\n items: [\n {\n author: 'admin',\n body: 'Say hi and tell us what you are building.',\n posted_at: '2026-01-01T00:00:00Z',\n },\n ],\n },\n ],\n },\n});\n",
7775
- "readme": '# Forum / Community template\n\nA forum backend \u2014 threads, replies, and votes \u2014 declared end-to-end in one typed `vxil.config.ts`.\nAuthors are verified end-users (`auth`), and thread activity streams live over `realtime`\nvia the config-only cms CDC bridge.\n\n**Provisions:**\n- `threads` \u2014 title (non-empty, hook-enforced), unique slug, `author` (owner-scoped), category,\n `replies_count`, `last_activity`, body.\n- `replies` \u2014 `thread` relation (slot-bound = join-able), `author` (owner-scoped), body, `posted_at`.\n- `votes` \u2014 `thread` relation, voter, and the unique composed key `pair` = `voter + \':\' + thread`.\n- `realtime` \u2014 thread/reply writes auto-publish onto the `threads:live` channel (`cdc` config bag).\n\n**Use it:**\n\n```bash\nvxil init --template community\nvxil quickstart\nvxil push # carries the `unique: true` claims on threads.slug + votes.pair (cms.md \xA79.3)\nvxil seed # the demo seed (1 thread + 1 reply) \u2014 push does not apply it\nvxil gen\n```\n\n**What to learn from this:**\n- **One vote per user, race-safe.** A `derive` hook composes `pair` server-side; the declarative\n `unique: true` on `votes.pair` (in the config, carried by `vxil push` \u2014 cms.md \xA79.3) makes the\n insert itself the guard \u2014 N racing votes yield exactly one 201, the rest `409 unique_violation`.\n No lock, no read-check-write. `voter` is asserted by your backend here; for direct end-user voting,\n declare `ownerField: \'voter\'` so the verified session id is enforced (cms.md \xA715).\n- **Atomic counters with `$inc`.** Bump `replies_count` in one conditional statement\n (`validation.min: 0` means a decrement can never go negative). Note: Lane-A hooks do NOT\n run on `$inc` \u2014 keep hook-guarded invariants off `$inc` fields.\n- **Join-filter reads (dotted keys).** `replies.thread` is a slot-bound relation, so a filter\n can reach the parent: `?filter={"thread.category":"announcements"}` lists replies whose\n thread is in a category \u2014 ops `$eq $ne $gt $gte $lt $lte $in` only (docs/features/cms.md \xA712.1).\n- **Live threads.** Subscribe a browser to `threads:live` with `@vxil/realtime` (token from your\n backend via `POST /v1/realtime/tokens`) and render `cms.item.created`/`updated` frames as they land.\n\n```bash\n# reply to a thread, then bump its counter atomically ($inc never mixes with data)\ncurl -X POST "https://api.vxil.com/v1/cms/items/replies" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"data":{"thread":"itm_THREAD","author":"u_42","body":"Hi!","posted_at":"2026-07-11T12:00:00Z"},"status":"published"}\'\ncurl -X PATCH "https://api.vxil.com/v1/cms/items/threads/itm_THREAD" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"$inc":{"replies_count":1}}\'\n# vote \u2014 the pair "u_42:itm_THREAD" is derived server-side; voting twice \u2192 409\ncurl -X POST "https://api.vxil.com/v1/cms/items/votes" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"data":{"thread":"itm_THREAD","voter":"u_42"},"status":"published"}\'\n```\n\n**Go deeper:** `docs/features/cms.md` (\xA77 hooks \xB7 \xA79.3 `$inc`/`unique` \xB7 \xA712.1 join filters \xB7\n\xA714 CDC \xB7 \xA715 `ownerField`), `docs/features/realtime.md`, `docs/features/auth.md`,\nand `examples/ecommerce/` for the same patterns under a checkout saga.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
7804
+ "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// \"Community\" \u2014 a forum backend (threads \u2192 replies, one-vote-per-user voting,\n// live updates), declared end-to-end in ONE typed file. A BLUEPRINT composing\n// shipped building blocks \u2014\n// \u2022 cms \u2192 threads \u2192 replies (resolved by relation) + votes\n// \u2022 auth \u2192 accounts, so an author is a verified end-user\n// \u2022 realtime \u2192 live thread updates (wired config-only via the cms CDC bridge)\n// The load-bearing tricks: `ownerField: 'author'` (a member edits only their\n// OWN posts), `$inc` on `replies_count` (atomic counter, no read-modify-write),\n// and the COMPOSED-KEY vote \u2014 `pair` = voter + ':' + thread, derived by a hook\n// and declared `unique: true` (carried by `vxil push`, cms.md \xA79.3), so N\n// racing votes yield exactly one 201. Everything here is DATA the tenant owns\n// 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 // Posts go live when created with `status: 'published'` (the README\n // curls do); keep drafts as a moderation hold state if you want a\n // review queue. (`vxil seed` sends no status, so seed items land as drafts.)\n draftPublish: true,\n hooks: {\n // A thread needs a real title \u2014 whitespace-only is rejected (Lane-A).\n thread_title: {\n collection: 'threads',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.title)) > 0',\n message: 'a thread needs a title',\n },\n // A reply needs a non-empty body.\n reply_body: {\n collection: 'replies',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.body)) > 0',\n message: 'a reply needs a body',\n },\n // ONE VOTE PER USER, half 1: both inputs must be present\u2026\n vote_inputs: {\n collection: 'votes',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.voter)) > 0 && len(trim(item.thread)) > 0',\n message: 'a vote needs a voter and a thread',\n },\n // \u2026half 2: derive the composed key `pair` = voter + ':' + thread\n // SERVER-SIDE (a client can never mis-compose it). Half 3 is the\n // `unique: true` claim on votes.pair below \u2014 the insert IS the guard.\n vote_pair: {\n collection: 'votes',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'pair',\n expr: \"concat(item.voter, ':', item.thread)\",\n },\n },\n // Realtime CDC bridge: thread + reply writes\n // auto-publish `cms.item.created/updated/published` frames onto the\n // 'threads:live' realtime channel \u2014 config-only live updates. At-most-once\n // (a live-view convenience; guaranteed delivery stays webhooks/functions).\n cdc: {\n threads_live: {\n collection: 'threads',\n channel: 'threads:live',\n events: ['created', 'updated', 'published'],\n payload: 'ids',\n },\n replies_live: {\n collection: 'replies',\n channel: 'threads:live',\n events: ['created', 'published'],\n payload: 'ids',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // members sign in as end-users\n realtime: {}, // defaults are fine \u2014 channels for the CDC frames above\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 threads: {\n singular: 'thread',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a verified member\n // may only edit their OWN threads. Server callers are unaffected.\n ownerField: 'author',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n // Declarative uniqueness (cms.md \xA79.3), carried by `vxil push`: a\n // duplicate slug is a clean 409 unique_violation. (Flipping the flag\n // on an ALREADY-pushed field is a dashboard/REST in-place alter \u2014\n // push's diff compares field name+type only.)\n slug: { type: 'string', indexSlot: 's2', unique: true },\n author: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n category: { type: 'string', indexSlot: 's4' },\n // validation.min: 0 makes `$inc: { replies_count: -1 }` a conditional\n // decrement \u2014 the counter can never go negative under races.\n replies_count: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_activity: { type: 'datetime', indexSlot: 't1' }, // \"hot threads\" sort key\n body: { type: 'text' },\n },\n },\n replies: {\n singular: 'reply',\n ownerField: 'author',\n fields: {\n // Slot-bound relation = the JOIN declaration: dotted filter keys like\n // {\"thread.category\": \"announcements\"} reach the parent thread\n // (vxil.com/docs/guide/04-data-with-cms).\n thread: { type: 'relation', relationTo: 'threads', indexSlot: 's1' },\n author: { type: 'string', indexSlot: 's2' },\n body: { type: 'text', required: true },\n posted_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n votes: {\n singular: 'vote',\n fields: {\n thread: { type: 'relation', relationTo: 'threads', indexSlot: 's1' },\n // The voting member's id \u2014 asserted by YOUR backend (server key) in\n // this blueprint's flow. For direct end-user voting, declare\n // `ownerField: 'voter'` so the verified session id is enforced\n // (cms.md \xA715) \u2014 at the cost of end-user keys then listing/counting\n // only their OWN votes.\n voter: { type: 'string', indexSlot: 's2' },\n // THE COMPOSED KEY \u2014 'voter:thread', derived by the vote_pair hook\n // above and declared unique: one vote per member per thread. N\n // racing votes \u2192 exactly one 201, the rest 409 unique_violation \u2014\n // no lock, no read-check-write.\n pair: { type: 'string', indexSlot: 's3', unique: true },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'threads',\n items: [\n {\n title: 'Welcome to the community',\n slug: 'welcome',\n author: 'admin',\n category: 'announcements',\n replies_count: 0,\n last_activity: '2026-01-01T00:00:00Z',\n body: 'Introduce yourself below.',\n },\n ],\n },\n {\n // Seed items are POSTed verbatim (no cross-item ref resolution), so this\n // reply carries no `thread` id \u2014 attach replies at runtime with the real\n // item_id (see the README snippet).\n collection: 'replies',\n items: [\n {\n author: 'admin',\n body: 'Say hi and tell us what you are building.',\n posted_at: '2026-01-01T00:00:00Z',\n },\n ],\n },\n ],\n },\n});\n",
7805
+ "readme": '# Forum / Community template\n\nA forum backend \u2014 threads, replies, and votes \u2014 declared end-to-end in one typed `vxil.config.ts`.\nAuthors are verified end-users (`auth`), and thread activity streams live over `realtime`\nvia the config-only cms CDC bridge.\n\n**Provisions:**\n- `threads` \u2014 title (non-empty, hook-enforced), unique slug, `author` (owner-scoped), category,\n `replies_count`, `last_activity`, body.\n- `replies` \u2014 `thread` relation (slot-bound = join-able), `author` (owner-scoped), body, `posted_at`.\n- `votes` \u2014 `thread` relation, voter, and the unique composed key `pair` = `voter + \':\' + thread`.\n- `realtime` \u2014 thread/reply writes auto-publish onto the `threads:live` channel (`cdc` config bag).\n\n**Use it:**\n\n```bash\nvxil init --template community\nvxil quickstart\nvxil push # carries the `unique: true` claims on threads.slug + votes.pair (cms.md \xA79.3)\nvxil seed # the demo seed (1 thread + 1 reply) \u2014 push does not apply it\nvxil gen\n```\n\n**What to learn from this:**\n- **One vote per user, race-safe.** A `derive` hook composes `pair` server-side; the declarative\n `unique: true` on `votes.pair` (in the config, carried by `vxil push` \u2014 cms.md \xA79.3) makes the\n insert itself the guard \u2014 N racing votes yield exactly one 201, the rest `409 unique_violation`.\n No lock, no read-check-write. `voter` is asserted by your backend here; for direct end-user voting,\n declare `ownerField: \'voter\'` so the verified session id is enforced (cms.md \xA715).\n- **Atomic counters with `$inc`.** Bump `replies_count` in one conditional statement\n (`validation.min: 0` means a decrement can never go negative). Note: Lane-A hooks do NOT\n run on `$inc` \u2014 keep hook-guarded invariants off `$inc` fields.\n- **Join-filter reads (dotted keys).** `replies.thread` is a slot-bound relation, so a filter\n can reach the parent: `?filter={"thread.category":"announcements"}` lists replies whose\n thread is in a category \u2014 ops `$eq $ne $gt $gte $lt $lte $in` only (vxil.com/docs/guide/04-data-with-cms).\n- **Live threads.** Subscribe a browser to `threads:live` with `@vxil/realtime` (token from your\n backend via `POST /v1/realtime/tokens`) and render `cms.item.created`/`updated` frames as they land.\n\n```bash\n# reply to a thread, then bump its counter atomically ($inc never mixes with data)\ncurl -X POST "https://api.vxil.com/v1/cms/items/replies" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"data":{"thread":"itm_THREAD","author":"u_42","body":"Hi!","posted_at":"2026-07-11T12:00:00Z"},"status":"published"}\'\ncurl -X PATCH "https://api.vxil.com/v1/cms/items/threads/itm_THREAD" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"$inc":{"replies_count":1}}\'\n# vote \u2014 the pair "u_42:itm_THREAD" is derived server-side; voting twice \u2192 409\ncurl -X POST "https://api.vxil.com/v1/cms/items/votes" \\\n -H "authorization: Bearer $VXIL_API_KEY" -H \'content-type: application/json\' \\\n -d \'{"data":{"thread":"itm_THREAD","voter":"u_42"},"status":"published"}\'\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (join filters \xB7 `ownerField`), vxil.com/docs/guide/07-validation-and-hooks,\nvxil.com/docs/api (`$inc`/`unique` on the item write routes), vxil.com/docs/guide/06-feature-catalog (realtime, auth),\nand `examples/ecommerce/` for the same patterns under a checkout saga.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
7806
+ "functions": {}
7807
+ },
7808
+ {
7809
+ "id": "live-room",
7810
+ "title": "Live Room (realtime \xB7 presence \xB7 feed \xB7 threads)",
7811
+ "vertical": "social",
7812
+ "summary": "Every live-collaboration primitive on one small config \u2014 channel tokens and a socket, presence roster and typing, an activity feed with follow/fan-out and a notification bell, threaded comments with reactions, direct-to-storage attachments and a one-time download link, a per-member rate-limit override, and a cms change-data rule that streams writes into a channel with no function at all.",
7813
+ "collections": [
7814
+ "rooms"
7815
+ ],
7816
+ "features": [
7817
+ "auth",
7818
+ "realtime",
7819
+ "presence",
7820
+ "comments",
7821
+ "activity-feed",
7822
+ "files",
7823
+ "rate-limits",
7824
+ "cms"
7825
+ ],
7826
+ "hasFunctions": false,
7827
+ "byoKeys": [],
7828
+ "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// \"Live Room\" \u2014 the REAL-TIME blueprint: every live-collaboration primitive\n// wired together on ONE small config, so you can see where each one's job\n// begins and ends.\n//\n// \u2022 realtime \u2192 the channel a browser holds open\n// \u2022 presence \u2192 who is in the room, and who is typing (socket-derived)\n// \u2022 activity-feed \u2192 follow a room, fan out its posts, ring the bell\n// \u2022 comments \u2192 the threads themselves (root + replies + reactions)\n// \u2022 files \u2192 attachments, uploaded straight to storage, plus a\n// hand-this-to-exactly-one-person link\n// \u2022 rate-limits \u2192 a posting budget, with one member exempted\n// \u2022 cms + cdc \u2192 the room records, streamed into a channel on write\n//\n// The division of labour worth internalising: realtime moves FRAMES, presence\n// answers WHO, the feed answers WHAT HAPPENED WHILE YOU WERE AWAY, comments\n// owns the durable thread, and cdc is the one-line bridge from a database write\n// to a live frame. None of them is a substitute for another.\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 // Members sign in so presence, feeds and comments all speak the same ids.\n auth: { methods: { emailPassword: true, magicLink: true } },\n\n // Live channels. A browser never holds an API key: your server mints a\n // short-lived channel token and the browser upgrades a socket with it.\n realtime: { enabled: true, maxConnectionsPerChannel: 200 },\n\n // Presence rides the SAME socket \u2014 join/leave are derived from the\n // connection, and `typing` is a message the client sends on the socket. It\n // is a second gate on top of realtime, not a separate transport.\n presence: { enabled: true },\n\n // Threads. `topic` is an opaque string YOU choose \u2014 this blueprint uses\n // `room:<slug>` so a whole room's thread is one prefix query.\n comments: {\n enabled: true,\n maxBodyLength: 4000,\n reactionsEnabled: true,\n editWindowMinutes: 15, // authors may fix a typo for 15 minutes\n },\n\n // Activity streams + the in-app notification feed.\n 'activity-feed': {\n enabled: true,\n feedGroups: {\n // a member's own authored activity\n user: { type: 'flat' },\n // what a member sees: everything they follow, newest first\n timeline: { type: 'flat', ranking: 'chronological' },\n // the bell: rolled up per verb+object+day, with seen/read state\n notification: { type: 'notification', aggregation: '{{ verb }}:{{ object }}:{{ time|date }}' },\n },\n fanout: { celebrityThreshold: 5000, maxFanoutPerJob: 500 },\n follow: { copyLimit: 50 }, // backfill this many posts when someone follows\n realtime: { enabled: true }, // push new activities + badge counts live\n },\n\n // Attachments. Bytes go from the browser straight to storage on a\n // pre-signed URL \u2014 they never transit the API.\n files: {\n enabled: true,\n uploadUrlTtl: 900,\n allowedContentTypes: ['image/png', 'image/jpeg', 'image/gif', 'application/pdf'],\n quotas: { maxObjectBytes: 25 * 1024 * 1024 },\n sharedLinks: { enabled: true, maxTtl: 24 * 3600 },\n },\n\n // The posting budget. The POLICY itself is not config \u2014 it is a row you\n // create over the API (one writer per datum), and so is the per-member\n // override. What lives here is the tenant-wide default envelope.\n 'rate-limits': { enabled: true, defaults: { perTenantSec: 50, burstSize: 100 } },\n\n cms: {\n // \u2500\u2500 THE ONE-LINE BRIDGE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // Every write to `rooms` is auto-published to the `lobby` channel as\n // `cms.item.<created|updated|deleted|published>`. No function, no\n // webhook, no polling. It is fire-and-forget and AT-MOST-ONCE: a live\n // view is exactly what it is for. When you need guaranteed delivery, use\n // an outbound webhook subscription or a function on the write instead.\n //\n // The channel name is LITERAL \u2014 it is not templated per item. One rule =\n // one channel; declare a second rule if you want a second channel.\n cdc: {\n lobby: {\n collection: 'rooms',\n channel: 'lobby',\n events: ['created', 'updated', 'deleted'],\n payload: 'full', // 'ids' to send only { collection, item_id, status }\n },\n },\n },\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 rooms: {\n singular: 'room',\n // Deliberately NOT owner-scoped: a room is shared by everyone in it.\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n // one room per slug \u2014 a duplicate is a clean 409, not a second room\n slug: { type: 'string', unique: true, indexSlot: 's2' },\n host: { type: 'string', indexSlot: 's3' }, // the member who opened it\n state: { type: 'string', indexSlot: 's4' }, // open | closed\n created_at: { type: 'datetime', indexSlot: 't1' },\n topic: { type: 'text' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'rooms',\n items: [\n {\n name: 'Design review',\n slug: 'design-review',\n host: 'user_demo',\n state: 'open',\n created_at: '2026-03-02T10:00:00Z',\n topic: 'Walk through the new onboarding flow and collect objections.',\n },\n ],\n },\n ],\n },\n});\n",
7829
+ "readme": '# Live Room (social)\n\nEvery live-collaboration primitive, wired together on one small config \u2014 so you can see where\neach one\'s job begins and ends instead of guessing which to reach for.\n\n```bash\nvxil init --template live-room\nvxil quickstart # or `vxil link <slug>` for an existing backend\nvxil push # the `rooms` collection + the change-data rule\n```\n\nNo functions. Nothing here needs code on the server: the whole blueprint is configuration plus\ncalls your app already makes.\n\n## Who does what\n\n| Primitive | Its one job | What it is NOT |\n|---|---|---|\n| `realtime` | move frames to everyone holding a channel open | not durable \u2014 a frame nobody was listening for is gone |\n| `presence` | answer *who is here, and who is typing* | not a member list \u2014 it is derived from live connections |\n| `activity-feed` | answer *what happened while I was away* | not a chat log \u2014 it is a ranked, followable stream |\n| `comments` | the durable thread: root, replies, reactions | not attachments \u2014 see below |\n| `files` | bytes, straight from the browser to storage | not a CDN in front of your database |\n| `rate-limits` | a budget per user, with exceptions | not authorization |\n| `cms` + `cdc` | turn a database write into a live frame, in one config line | not delivery you can rely on \u2014 see below |\n\nThe division that matters: **realtime is transport, the feed is memory.** A member who was offline\nlearns what happened from the feed, never from the channel. Build both or people will keep asking\n"did you see my message?".\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `realtime:write presence:read comments:read comments:write files:read\nfiles:write activity-feed:read activity-feed:write ratelimits:read ratelimits:write ratelimits:check\ncms:read cms:write`; `$API` is `https://api.vxil.com`.\n\n**1. A browser gets a token, not a key.** Your server mints a short-lived channel token; the browser\nupgrades a socket with it. The API key never leaves your backend.\n\n```bash\nvxil api POST /v1/realtime/tokens --data \'{"channel":"room-design-review","user_id":"user_ana","ttl_seconds":120}\'\n# 200 { "data": { "token": "\u2026", "connect_path": "\u2026", "expires_at": "\u2026" } }\n```\n\nOpen the socket at `connect_path` (it is a `wss://` upgrade on the same host). The TTL is clamped\nto 10\u2013300 seconds on purpose: a token is a door, not a password. In end-user mode the token\'s\nsubject is forced to the verified user \u2014 a client cannot mint a token as somebody else.\n\nThe moment the socket opens it receives `presence.state` with the current roster; everyone else\nreceives `presence.join`. Send `{"type":"typing"}` on the socket and the room sees\n`presence.typing` (once a second, per socket). Close it and the last connection for that user\nemits `presence.leave`. None of that is a REST call \u2014 **presence is derived from the connection**,\nwhich is why there is no join, leave, or heartbeat endpoint to get wrong.\n\n**2. Your server publishes.** Only your server:\n\n```bash\nvxil api POST /v1/realtime/channels/room-design-review/publish \\\n --data \'{"event":"room.note","data":{"text":"Starting in 5"}}\'\n# 200 { "data": { "channel": "room-design-review", "event": "room.note", "delivered": 2 } }\n```\n\n`delivered` counts frames actually sent \u2014 a socket that is closed or too far behind is skipped and\nnot counted, so the number is honest. Publishing is refused for an end-user principal\n(`403 server_only`): a browser cannot broadcast to a room over REST. And the `presence.*` event\nnamespace is reserved:\n\n```bash\nvxil api POST /v1/realtime/channels/room-design-review/publish --data \'{"event":"presence.fake"}\'\n# 422 \u2026 the \'presence.*\' event namespace is reserved\n```\n\n**3. Who is in the room.**\n\n```bash\nvxil api GET /v1/realtime/channels/room-design-review/presence\n# 200 { "data": { "channel": "room-design-review",\n# "users": [ { "user_id": "user_ana", "connections": 2 } ],\n# "total_connections": 2 } }\n```\n\n`connections` is per user, not per person-shaped guess \u2014 two tabs are two connections and one\nmember.\n\n**4. The durable thread.** A comment hangs off a `topic`, which is **an opaque string you choose**.\nThis blueprint uses `room:<slug>`, so a whole room\'s thread is one prefix:\n\n```bash\nvxil api POST /v1/comments --data \'{"topic":"room:design-review","author_id":"user_ana","body":"Objection: the empty state is confusing."}\'\n# 201 { "data": { "comment_id": "cmt_\u2026", "topic": "room:design-review", "author_id": "user_ana", \u2026 } }\n\nvxil api POST /v1/comments --data \'{"topic":"room:design-review","author_id":"user_bo","parent_id":"cmt_\u2026","body":"Agreed \u2014 screenshot below."}\'\nvxil api POST /v1/comments/cmt_\u2026/reactions --data \'{"emoji":"\u{1F389}","author_id":"user_bo"}\'\n# 200 \u2014 a toggle: the same emoji from the same author removes it again\n```\n\nA thread is the root comment plus its `parent_id` children; there is no separate thread object to\ncreate and no `resolved` flag \u2014 resolution is a decision your app makes, not one vxil imposes.\n\n**5. Attachments \u2014 the honest version.** There is **no typed attachment field on a comment**. Files\nare their own feature, and the link between them is a convention *you* own. The upload is three\nsteps, and the bytes never pass through the API. (The `user_id` must be a user this backend knows \u2014\n`POST /v1/users` first, or sign them in through `auth`; an unknown id is a clean\n`user_not_found`.)\n\n```bash\nvxil api POST /v1/files/upload-url --data \'{"user_id":"user_bo","filename":"empty-state.png","content_type":"image/png","size_bytes":48210}\'\n# 201 { "data": { "object_id": "obj_\u2026", "upload_url": "\u2026", "upload_method": "PUT", "expires_in": 900 } }\n\ncurl -s -X PUT "<upload_url>" -H \'content-type: image/png\' --data-binary @empty-state.png\n\nvxil api POST /v1/files/obj_\u2026/complete\n# 200 { "data": { "object_id": "obj_\u2026", "status": "available", "checksum_sha256": "\u2026" } }\n```\n\nStep 2 goes straight to storage on a pre-signed URL whose content type **and** length are part of\nthe signature, so a client cannot quietly upload something else. Step 3 verifies the bytes landed\nat the size you declared \u2014 until it runs, the object is `pending` and undownloadable.\n\nThen put the id in the comment body with whatever marker your renderer understands\n(`![empty state](vxil-file:obj_\u2026)` works fine). Your client reads the marker and calls\n`GET /v1/files/{object_id}/download-url` for a short-lived link.\n\n**6. A link you can hand to exactly one person.** For the case where a *non-member* needs one file\n\u2014 an export, a signed page \u2014 mint a shared link that dies on first use:\n\n```bash\nvxil api POST /v1/files/obj_\u2026/shared-links --data \'{"max_downloads":1,"ttl_seconds":3600}\'\n# 201 { "data": { "link_id": "shl_\u2026", "url": "\u2026", "expires_in": 3600,\n# "expires_at": "\u2026", "max_downloads": 1, "downloads": 0 } }\n```\n\nOpen the returned `url` with no key at all and the file downloads. Open it a second time:\n\n```\n410 { "error": { "code": "link_exhausted", \u2026 } }\n```\n\nThe counter is burned inside the same conditional write that hands out the file, so two people\nclicking at the same instant cannot both win. Leave `max_downloads` off for an unlimited link;\n`ttl_seconds` is clamped to the `sharedLinks.maxTtl` you set in config.\n\n**7. The feed: follow, fan out, and the bell.** An activity is `actor` / `verb` / `object`\n(plus an optional `target`) \u2014 the shape every activity-stream system settled on.\n\n```bash\n# bo follows ana\'s personal feed\nvxil api POST /v1/feeds/timeline/user_bo/follows --data \'{"target":"user:user_ana"}\'\n# 201 { "data": { "follower": "timeline:user_bo", "followed": [ "user:user_ana" ] } }\n\n# ana posts \u2014 it fans out to her followers\' timelines, and pings bo\'s bell directly\nvxil api POST /v1/feeds/user/user_ana/activities \\\n --data \'{"actor":"user_ana","verb":"post","object":"room:design-review","foreign_id":"post-1","to":["notification:user_bo"]}\'\n# 201 { "data": { "activities": [ { "id": "\u2026", "actor": "user_ana", "verb": "post", \u2026 } ] } }\n\nvxil api GET /v1/feeds/timeline/user_bo\n# 200 { "data": { "feed": "timeline:user_bo", "type": "flat", "activities": [ \u2026 ], "next_cursor": \u2026 } }\n\nvxil api GET /v1/feeds/notification/user_bo/count\n# 200 { "data": { "unseen": 1, "unread": 1, "total": 1 } }\n\nvxil api POST /v1/feeds/notification/user_bo/read --data \'{}\'\n# 200 { "data": { "user_id": "user_bo", "action": "read", "unseen": 0, "unread": 0, "total": 1 } }\n```\n\n`foreign_id` makes the write idempotent \u2014 the same `(foreign_id, time)` pair upserts instead of\nduplicating, which is exactly what you want when a retry replays a post. The notification group is\naggregated (`{{ verb }}:{{ object }}:{{ time|date }}`), so twelve likes on one post are one badge\nline, not twelve.\n\n**8. A posting budget, with one exception.** The budget is a *row*, not config \u2014 so is the\nexception:\n\n```bash\nvxil api POST /v1/rate-limits/policies --data \'{"name":"room-post","key_template":"post:{user_id}","limit":5,"window_seconds":60}\'\n# 201 { "data": { "policy_id": "rl_\u2026", "name": "room-post", "key_template": "post:{user_id}",\n# "limit": 5, "window_seconds": 60, "behavior": "block", "algorithm": "sliding_window", \u2026 } }\n\n# the host gets more headroom than everyone else\nvxil api POST /v1/rate-limits/policies/rl_\u2026/overrides --data \'{"pattern":"post:user_ana","limit":100,"note":"room host"}\'\n# 201 { "data": { "override_id": "rlo_\u2026", "pattern": "post:user_ana", "limit": 100, \u2026 } }\n\nvxil api POST /v1/rate-limits/check --data \'{"policy_id":"rl_\u2026","key_values":{"user_id":"user_bo"}}\'\n# 200 { "data": { "allowed": true, "remaining": 4, "reset_seconds": 60, "behavior": "block" } }\n# \u2026and as user_ana the same call answers `"remaining": 99` with the `override_id` that granted it.\n```\n\nCall it six times as `user_bo` and the sixth is `429 rate_limited` with `Retry-After`; as\n`user_ana` it keeps going. A `pattern` may be a literal or a glob (`post:staff-*`); an exact match\nalways beats a glob, and the longest literal prefix wins between globs. Give an override up to\nabout half a minute to take effect \u2014 the lookup is cached, which is the price of it costing nothing\non the hot path.\n\n**9. One config line, and a database write becomes a live frame.** The `cdc` rule in\n`vxil.config.ts` is already pushed. Write a room:\n\n```bash\nvxil api PATCH /v1/cms/items/rooms/<room item_id> --data \'{"data":{"state":"closed"}}\'\n```\n\nAnyone holding the `lobby` channel receives:\n\n```json\n{ "event": "cms.item.updated",\n "data": { "collection": "rooms", "item_id": "itm_\u2026", "status": "draft",\n "at": "\u2026", "request_id": "req_\u2026", "data": { "name": "Design review", "state": "closed", \u2026 } },\n "ts": 1772000000000 }\n```\n\nNo function, no polling, no second write path. Two things to know before you rely on it: the\nchannel name is **literal**, not templated \u2014 one rule is one channel, so declare a second rule for\na second channel \u2014 and delivery is **at-most-once**, fire-and-forget after the write commits. It is\na live view. When a missed event would be a bug, use a durable subscription or a function on the\nwrite instead, both of which ride the at-least-once spine.\n\n## The client half\n\nThe browser side of this is published, so you do not write it twice:\n\n- [`@vxil/realtime`](../../packages/realtime) \u2014 the reconnecting channel client (`RealtimeClient`,\n `createRealtimeClient`), including the presence events.\n- [`@vxil/react/feed`](../../packages/react) \u2014 `FeedProvider`, `FeedTimeline` and `NotificationBell`\n over the feed API, with the badge count kept live.\n- [`@vxil/react/comments`](../../packages/react) \u2014 `CommentThread` plus `UploadWidget` / `Dropzone`,\n which drive exactly the three-step upload above.\n\nThe uploader and the thread are separate components for the same reason the API has no attachment\nfield: the link between a file and a comment is your product\'s decision.\n\n## What to learn from this\n\n- **A browser holds a token, never a key.** Every live surface here is reachable from a client\n because the client is handed a short-lived, subject-bound credential your server minted.\n- **Presence is not a table.** It is whatever sockets are open right now. Treating it as durable\n state is the classic mistake; the roster endpoint exists for the first paint, not as a source of\n truth.\n- **Transport and memory are different features.** The channel is for people who are here. The feed\n is for people who were not. Every "unread" bug is one of those two doing the other\'s job.\n- **Idempotency is a field, not a promise.** `foreign_id` on an activity, `unique` on a cms field,\n `max_downloads` on a link \u2014 each one turns "please don\'t run twice" into something the database\n enforces.\n- **A change-data rule is a live view, not a delivery guarantee.** One line gets you a stream. Two\n words \u2014 *at most once* \u2014 tell you when not to use it.\n\n**Pairs with:** `templates/community/` (threads and votes without the live layer) and\n`templates/microblog/` (follows and a public timeline).\n',
7776
7830
  "functions": {}
7777
7831
  },
7778
7832
  {
@@ -7795,7 +7849,7 @@ var TEMPLATE_CATALOG = [
7795
7849
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7796
7850
  // "Docs Site" \u2014 a PUBLIC content site (documentation + changelog), declared
7797
7851
  // end-to-end in ONE typed file. This is the canonical showcase of cms PUBLIC
7798
- // DELIVERY (docs/features/cms.md \xA716): every reader-facing collection is
7852
+ // DELIVERY (vxil.com/docs/guide/04-data-with-cms): every reader-facing collection is
7799
7853
  // \`public: true\`, so a static/JAMstack front-end serves the whole site over the
7800
7854
  // KEYLESS, edge-cached GET /v1/cms/public/:tenantId/:collection lane \u2014 no API key
7801
7855
  // on the read path at all. A BLUEPRINT composing shipped building blocks \u2014
@@ -7919,7 +7973,7 @@ export default defineConfig({
7919
7973
  },
7920
7974
  });
7921
7975
  `,
7922
- "readme": "# Docs Site / Changelog template\n\nA **public content site** \u2014 documentation sections and pages plus a changelog feed \u2014 declared end-to-end\nin one typed `vxil.config.ts`. This is the canonical showcase of **cms public delivery**\n(`docs/features/cms.md` \xA716): every reader-facing collection is `public: true`, so a static/JAMstack\nfront-end serves the whole site over the **keyless, edge-cached** `GET /v1/cms/public/:tenantId/:collection`\nlane \u2014 **no API key on the read path at all**. Authors write drafts behind an API key; readers see only\n**published** rows, served from the edge cache.\n\n**What it provisions (all `cms` collections you own and can edit):**\n- `sections` \u2014 the doc nav tree (title, unique `slug`, `order` for the sidebar, summary). **Public.**\n- `pages` \u2014 the documentation pages (title, unique `slug`, a `section` relation slot-bound for the\n single-hop join, `order`, markdown `body`, `updated_at`, JSON `tags`). **Public.** A Lane-A hook\n requires a title and a lowercase slug.\n- `changelog` \u2014 a release feed (`version`, `title`, `kind`, `released_at`, markdown `body`). **Public.**\n A Lane-A hook requires a version.\n- Feature: `cms` with `draftPublish` \u2014 write as draft, publish live.\n\n**Apply it:**\n\n```bash\nvxil init --template docs-site\nvxil quickstart # a fresh backend (or `vxil link <slug>` for an existing one)\nvxil push # apply the collections + hooks (the `public: true` flags ride push \u2014 cms.md \xA716)\nvxil gen # typed SDK + per-tenant MCP catalog\n```\n\n## The whole point: a keyless public read path\n\nBecause `sections`, `pages`, and `changelog` are `public: true`, your front-end reads them with **no API\nkey** \u2014 the edge mints a restricted read-only token bound to your tenant, forces `status = 'published'`,\nand edge-caches the response (`s-maxage=60`, `stale-while-revalidate`). Drafts are never served.\n\n```ts\n// Reader front-end \u2014 NO api key, no Vxil client, no auth. The served SDK's\n// keyless helper (docs/features/cms.md \xA716); or hit the URL with plain fetch.\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n\nconst TENANT = 'ten_your_tenant_id';\n\n// the sidebar: published sections, in order\nconst { items: sections } = await listCmsPublic(TENANT, 'sections', { sort: 'order', limit: 100 });\n\n// one page by slug (published only \u2014 a draft slug 404s to the reader)\nconst { items: [page] } = await listCmsPublic(TENANT, 'pages', { filter: { slug: 'introduction' }, limit: 1 });\n\n// every page in the \"guides\" section \u2014 the \xA712.1 single-hop dotted-key join\nconst guides = await listCmsPublic(TENANT, 'pages', { filter: { 'section.slug': 'guides' }, sort: 'order' });\n\n// the changelog, newest first (released_at is slot-bound \u21D2 index-served)\nconst { items: releases } = await listCmsPublic(TENANT, 'changelog', { sort: '-released_at', limit: 25 });\n```\n\nOr with plain `fetch` (any language, any runtime):\n\n```bash\ncurl \"https://api.vxil.com/v1/cms/public/$TENANT/pages?sort=order&limit=100\"\n```\n\n## What to learn from this\n\n1. **Public delivery is one collection flag** (`docs/features/cms.md` \xA716) \u2014 `public: true` opts a\n collection into the keyless lane; it is layered **on top of** the tenant isolation boundary, never replacing it.\n Carried by **both** push paths (the `vxil push` reconciler AND control-plane `/v1/apply`), so the flag\n is not read-only inert.\n2. **Published-only, default-deny** \u2014 the lane FORCES `status = 'published'`; a `$status` filter is\n rejected, so no query param can ever surface a draft. Keep a page draft while writing and it stays\n private until you publish it \u2014 you can stage a whole release behind an API key, then publish atomically.\n3. **Owner ids never leak** \u2014 this lane is owner-**unscoped** by design (public content is not per-user);\n if a public collection also declares an `ownerField`, it is stripped from every served row. (The doc\n collections here declare none \u2014 they are shared content.)\n4. **Editorial invariants ride as tenant-owned Lane-A hooks** \u2014 \"a page needs a title\", \"slug must be\n lowercase\", \"a release needs a version\" are AST-checked safe expressions in **your** config, run inside\n the write transaction (\xA77), not platform code.\n5. **Honest limits** (\xA716) \u2014 the public lane is **read-only** (no keyless writes), serves the safe query\n subset (`filter`/`sort`/`limit`/`cursor`) only \u2014 not the \xA712 relational read-models \u2014 and never more\n than **100 rows/page**. Freshness is eventually-consistent within `s-maxage=60` of a change.\n\n## Add reader comments (optional)\n\nTo let readers comment on a page, enable the `comments` feature and drop the\n[`@vxil/react/comments`](../../packages/react) widget onto your page (a pure client-side component\nover the shipped comments API). The public docs stay keyless; comments authenticate as end-users.\n\n**Go deeper:** `docs/features/cms.md` (\xA73 query DSL \xB7 \xA77 hooks \xB7 \xA712.1 joins \xB7 **\xA716 public delivery**) \xB7\n`templates/blog/` (an editorial variant with reader comments) \xB7 `templates/catalog/` (a public product grid) \xB7\n`docs/cms-content-templates-analysis.md`.\n\n**Own the shape.** The config is yours after `init` \u2014 add a `docs`-vs-`api` section type, an `authors`\nrelation, a search-index collection. Nothing is locked.\n",
7976
+ "readme": "# Docs Site / Changelog template\n\nA **public content site** \u2014 documentation sections and pages plus a changelog feed \u2014 declared end-to-end\nin one typed `vxil.config.ts`. This is the canonical showcase of **cms public delivery**\n(vxil.com/docs/guide/04-data-with-cms): every reader-facing collection is `public: true`, so a static/JAMstack\nfront-end serves the whole site over the **keyless, edge-cached** `GET /v1/cms/public/:tenantId/:collection`\nlane \u2014 **no API key on the read path at all**. Authors write drafts behind an API key; readers see only\n**published** rows, served from the edge cache.\n\n**What it provisions (all `cms` collections you own and can edit):**\n- `sections` \u2014 the doc nav tree (title, unique `slug`, `order` for the sidebar, summary). **Public.**\n- `pages` \u2014 the documentation pages (title, unique `slug`, a `section` relation slot-bound for the\n single-hop join, `order`, markdown `body`, `updated_at`, JSON `tags`). **Public.** A Lane-A hook\n requires a title and a lowercase slug.\n- `changelog` \u2014 a release feed (`version`, `title`, `kind`, `released_at`, markdown `body`). **Public.**\n A Lane-A hook requires a version.\n- Feature: `cms` with `draftPublish` \u2014 write as draft, publish live.\n\n**Apply it:**\n\n```bash\nvxil init --template docs-site\nvxil quickstart # a fresh backend (or `vxil link <slug>` for an existing one)\nvxil push # apply the collections + hooks (the `public: true` flags ride push \u2014 cms.md \xA716)\nvxil gen # typed SDK + per-tenant MCP catalog\n```\n\n## The whole point: a keyless public read path\n\nBecause `sections`, `pages`, and `changelog` are `public: true`, your front-end reads them with **no API\nkey** \u2014 the edge mints a restricted read-only token bound to your tenant, forces `status = 'published'`,\nand edge-caches the response (`s-maxage=60`, `stale-while-revalidate`). Drafts are never served.\n\n```ts\n// Reader front-end \u2014 NO api key, no Vxil client, no auth. The served SDK's\n// keyless helper (vxil.com/docs/guide/04-data-with-cms); or hit the URL with plain fetch.\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n\nconst TENANT = 'ten_your_tenant_id';\n\n// the sidebar: published sections, in order\nconst { items: sections } = await listCmsPublic(TENANT, 'sections', { sort: 'order', limit: 100 });\n\n// one page by slug (published only \u2014 a draft slug 404s to the reader)\nconst { items: [page] } = await listCmsPublic(TENANT, 'pages', { filter: { slug: 'introduction' }, limit: 1 });\n\n// every page in the \"guides\" section \u2014 the \xA712.1 single-hop dotted-key join\nconst guides = await listCmsPublic(TENANT, 'pages', { filter: { 'section.slug': 'guides' }, sort: 'order' });\n\n// the changelog, newest first (released_at is slot-bound \u21D2 index-served)\nconst { items: releases } = await listCmsPublic(TENANT, 'changelog', { sort: '-released_at', limit: 25 });\n```\n\nOr with plain `fetch` (any language, any runtime):\n\n```bash\ncurl \"https://api.vxil.com/v1/cms/public/$TENANT/pages?sort=order&limit=100\"\n```\n\n## What to learn from this\n\n1. **Public delivery is one collection flag** (vxil.com/docs/guide/04-data-with-cms) \u2014 `public: true` opts a\n collection into the keyless lane; it is layered **on top of** the tenant isolation boundary, never replacing it.\n Carried by **both** push paths (the `vxil push` reconciler AND control-plane `/v1/apply`), so the flag\n is not read-only inert.\n2. **Published-only, default-deny** \u2014 the lane FORCES `status = 'published'`; a `$status` filter is\n rejected, so no query param can ever surface a draft. Keep a page draft while writing and it stays\n private until you publish it \u2014 you can stage a whole release behind an API key, then publish atomically.\n3. **Owner ids never leak** \u2014 this lane is owner-**unscoped** by design (public content is not per-user);\n if a public collection also declares an `ownerField`, it is stripped from every served row. (The doc\n collections here declare none \u2014 they are shared content.)\n4. **Editorial invariants ride as tenant-owned Lane-A hooks** \u2014 \"a page needs a title\", \"slug must be\n lowercase\", \"a release needs a version\" are AST-checked safe expressions in **your** config, run inside\n the write transaction (\xA77), not platform code.\n5. **Honest limits** (\xA716) \u2014 the public lane is **read-only** (no keyless writes), serves the safe query\n subset (`filter`/`sort`/`limit`/`cursor`) only \u2014 not the \xA712 relational read-models \u2014 and never more\n than **100 rows/page**. Freshness is eventually-consistent within `s-maxage=60` of a change.\n\n## Add reader comments (optional)\n\nTo let readers comment on a page, enable the `comments` feature and drop the\n[`@vxil/react/comments`](../../packages/react) widget onto your page (a pure client-side component\nover the shipped comments API). The public docs stay keyless; comments authenticate as end-users.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 joins \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\n`templates/blog/` (an editorial variant with reader comments) \xB7 `templates/catalog/` (a public product grid).\n\n**Own the shape.** The config is yours after `init` \u2014 add a `docs`-vs-`api` section type, an `authors`\nrelation, a search-index collection. Nothing is locked.\n",
7923
7977
  "functions": {}
7924
7978
  },
7925
7979
  {
@@ -7942,12 +7996,43 @@ export default defineConfig({
7942
7996
  "byoKeys": [
7943
7997
  "openai_key"
7944
7998
  ],
7945
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"AI Journal\" \u2014 an AI-powered private journal, declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 owner-scoped `entries` (each journal belongs to its writer)\n// \u2022 ai \u2192 the enrichment calls (summary + mood) \u2014 BYO provider key\n// \u2022 rag \u2192 \"ask your journal\": retrieval-grounded answers with citations\n// \u2022 vector-search \u2192 rag's retrieval leg (mock embedder by default, zero-config)\n// \u2022 functions \u2192 the async glue: enrich-on-write, ask endpoint, weekly cron\n// \u2022 notifications \u2192 the weekly digest email (mock provider until you wire one)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n//\n// ONE-TIME SETUP after `vxil push`: create the retrieval index (a vector-search\n// collection) once \u2014\n// curl -X POST https://api.vxil.com/v1/search/collections \\\n// -H \"Authorization: Bearer $VXIL_KEY\" -H \"Content-Type: application/json\" \\\n// -d '{\"collection\":\"journal\"}'\n// (dimensions/embedder come from the vector-search config defaults below).\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // Every entry needs a title \u2014 a pure function of the row (Lane-A validate).\n entry_title: {\n collection: 'entries',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.title) > 0',\n message: 'an entry needs a title',\n },\n // Stamp written_at when the client omits it (Lane-A derive; `now` \u2014 one\n // ISO per request \u2014 is the write path's only ambient input).\n entry_written_at: {\n collection: 'entries',\n event: 'beforeCreate',\n kind: 'derive',\n field: 'written_at',\n expr: 'coalesce(item.written_at, now)',\n },\n },\n },\n\n // The AI enrichment runs keyless out of the box: 'mock' is the deterministic\n // default provider. Go real by (1) `vxil secrets set ai/openai_key`, then\n // (2) flipping defaultProvider to 'openai' and defaults.model to a real one\n // (e.g. 'gpt-4.1-mini'). The keyRef below already points at the secret.\n ai: {\n defaultProvider: 'mock',\n providers: { openaiKeyRef: 'openai_key' }, // \u2192 secrets.openai_key (envelope-encrypted)\n defaults: { model: 'mock-1', maxTokens: 512, temperature: 0.4 },\n },\n\n // rag owns the retrieve\u2192ground\u2192generate\u2192cite pipeline; the prompt/synthesis\n // stay yours. `defaultCollection` lets callers omit `collection`.\n rag: {\n defaultCollection: 'journal',\n retrieval: { topK: 6 },\n },\n\n // rag's retrieval leg \u2014 must be enabled or /v1/rag/* answers 501. The 'mock'\n // embedder is the zero-config default; for real embeddings set\n // embed: { provider: 'openai', model: 'text-embedding-3-small', apiKeyRef: \u2026 }.\n 'vector-search': {},\n\n notifications: { provider: 'mock', fromEmail: 'digest@journal.app' },\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 entries: {\n singular: 'entry',\n // End-user owner-scope (docs/features/cms.md \xA715): a verified end-user\n // reads/writes only their OWN entries. Server callers are unaffected.\n ownerField: 'user_id',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n body: { type: 'text' },\n mood: { type: 'string', indexSlot: 's2' }, // AI-derived by on-entry-written\n summary: { type: 'text' }, // AI-derived by on-entry-written\n written_at: { type: 'datetime', indexSlot: 't1' },\n user_id: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n tags: { type: 'json' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions (async, cross-feature) \u2500\u2500\n functions: {\n // Enrich on write: re-fetch the entry, ai-generate a 1-sentence summary +\n // one-word mood, PATCH them back, ingest title+body into the rag index.\n 'on-entry-written': {\n entry: './functions/on-entry-written.ts',\n trigger: { kind: 'cmsHook', collection: 'entries', event: 'beforeWrite' },\n scopes: ['cms:read', 'cms:write', 'ai:write', 'rag:write'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // \"Ask your journal\": POST /v1/fn/ask-journal { question } \u2192 a grounded\n // answer with citations via POST /v1/rag/answer.\n 'ask-journal': {\n entry: './functions/ask-journal.ts',\n trigger: { kind: 'http' },\n scopes: ['rag:write'],\n },\n // Weekly digest: every Monday 08:00 UTC, list the week's entries and send\n // each writer a notifications digest.\n 'weekly-digest': {\n entry: './functions/weekly-digest.ts',\n trigger: { kind: 'cron', schedule: '0 8 * * 1' },\n scopes: ['cms:read', 'notifications:send'],\n },\n },\n\n // \u2500\u2500 SECRET REFERENCES (never values) \u2014 `vxil secrets set ai/openai_key` \u2500\u2500\n secrets: {\n openai_key: { feature: 'ai', description: 'OpenAI API key (BYO provider, envelope-encrypted)' },\n },\n\n seed: {\n cms: [\n {\n collection: 'entries',\n items: [\n {\n title: 'Welcome to your AI journal',\n body: 'Write anything. On every save, a function summarizes the entry, names its mood, and indexes it \u2014 then you can literally ask your journal questions.',\n written_at: '2026-07-01T09:00:00Z',\n user_id: 'demo-user',\n tags: ['welcome'],\n },\n ],\n },\n ],\n },\n});\n",
7946
- "readme": '# AI Journal template\n\nAn AI-powered private journal \u2014 declared end-to-end in one typed `vxil.config.ts`. Every saved entry is\nenriched by a function (one-sentence summary + one-word mood via the `ai` feature) and indexed for retrieval,\nso you can literally *ask your journal* and get grounded, cited answers back.\n\n**What it provisions:**\n- `entries` \u2014 title, body, AI-derived `mood`/`summary`, `written_at`, tags, and `user_id` as the **owner field**\n (a verified end-user only sees their own journal). Lane-A hooks require a title and stamp `written_at`.\n- Features: `cms` + `ai` + `rag` + `vector-search` (rag\'s retrieval leg) + `notifications` + `functions`.\n- Functions: `on-entry-written` (cmsHook: enrich + ingest), `ask-journal` (http: grounded Q&A),\n `weekly-digest` (cron: Monday digest per writer).\n\n**Apply it:**\n\n```bash\nvxil init --template ai-journal\nvxil quickstart # or `vxil link` an existing tenant\nvxil push\nvxil gen\n# one-time: create the retrieval index (dimensions/embedder come from config defaults)\ncurl -X POST https://api.vxil.com/v1/search/collections \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"collection":"journal"}\'\n```\n\n**What to learn from this:**\n1. **AI enrichment on write** \u2014 a `cmsHook` function re-fetches the entry by `item_id` (never trusts inline\n fields), calls `POST /v1/ai/generate` (raw-prompt mode), PATCHes `summary`/`mood` back, and latches on\n `summary` so its own write-back never re-enriches.\n2. **Retrieval-augmented "ask your journal"** \u2014 `POST /v1/rag/answer` retrieves top-k from the `journal`\n index and returns the answer *with citations* (`doc_id` = the entry\'s `item_id`); the prompt stays yours.\n3. **BYO AI key via encrypted secrets** \u2014 config carries only the reference (`providers.openaiKeyRef`);\n `vxil secrets set ai/openai_key` stores the value envelope-encrypted, then flip `defaultProvider`/`model`.\n Until then the deterministic `mock` provider (and mock embedder) run the whole loop keyless.\n\n```bash\ncurl -X POST https://api.vxil.com/v1/fn/ask-journal \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"question":"what made me happy this month?"}\'\n```\n\n**Go deeper:** `docs/features/ai.md` \xB7 `docs/features/rag.md` \xB7 `docs/features/functions.md` \xB7\n`docs/features/cms.md` (\xA77 hooks, \xA715 owner-scope) \xB7 `examples/ecommerce/` (a bigger functions saga).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
7999
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"AI Journal\" \u2014 an AI-powered private journal, declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 owner-scoped `entries` (each journal belongs to its writer)\n// \u2022 ai \u2192 the enrichment calls (summary + mood) \u2014 BYO provider key\n// \u2022 rag \u2192 \"ask your journal\": retrieval-grounded answers with citations\n// \u2022 vector-search \u2192 rag's retrieval leg (mock embedder by default, zero-config)\n// \u2022 functions \u2192 the async glue: enrich-on-write, ask endpoint, weekly cron\n// \u2022 notifications \u2192 the weekly digest email (mock provider until you wire one)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n//\n// ONE-TIME SETUP after `vxil push`: create the retrieval index (a vector-search\n// collection) once \u2014\n// curl -X POST https://api.vxil.com/v1/search/collections \\\n// -H \"Authorization: Bearer $VXIL_KEY\" -H \"Content-Type: application/json\" \\\n// -d '{\"collection\":\"journal\"}'\n// (dimensions/embedder come from the vector-search config defaults below).\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // Every entry needs a title \u2014 a pure function of the row (Lane-A validate).\n entry_title: {\n collection: 'entries',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.title) > 0',\n message: 'an entry needs a title',\n },\n // Stamp written_at when the client omits it (Lane-A derive; `now` \u2014 one\n // ISO per request \u2014 is the write path's only ambient input).\n entry_written_at: {\n collection: 'entries',\n event: 'beforeCreate',\n kind: 'derive',\n field: 'written_at',\n expr: 'coalesce(item.written_at, now)',\n },\n },\n },\n\n // The AI enrichment runs keyless out of the box: 'mock' is the deterministic\n // default provider. Go real by (1) `vxil secrets set ai/openai_key`, then\n // (2) flipping defaultProvider to 'openai' and defaults.model to a real one\n // (e.g. 'gpt-4.1-mini'). The keyRef below already points at the secret.\n ai: {\n defaultProvider: 'mock',\n providers: { openaiKeyRef: 'openai_key' }, // \u2192 secrets.openai_key (envelope-encrypted)\n defaults: { model: 'mock-1', maxTokens: 512, temperature: 0.4 },\n },\n\n // rag owns the retrieve\u2192ground\u2192generate\u2192cite pipeline; the prompt/synthesis\n // stay yours. `defaultCollection` lets callers omit `collection`.\n rag: {\n defaultCollection: 'journal',\n retrieval: { topK: 6 },\n },\n\n // rag's retrieval leg \u2014 must be enabled or /v1/rag/* answers 501. The 'mock'\n // embedder is the zero-config default; for real embeddings set\n // embed: { provider: 'openai', model: 'text-embedding-3-small', apiKeyRef: \u2026 }.\n 'vector-search': {},\n\n notifications: { provider: 'mock', fromEmail: 'digest@journal.app' },\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 entries: {\n singular: 'entry',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a verified end-user\n // reads/writes only their OWN entries. Server callers are unaffected.\n ownerField: 'user_id',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n body: { type: 'text' },\n mood: { type: 'string', indexSlot: 's2' }, // AI-derived by on-entry-written\n summary: { type: 'text' }, // AI-derived by on-entry-written\n written_at: { type: 'datetime', indexSlot: 't1' },\n user_id: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n tags: { type: 'json' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions (async, cross-feature) \u2500\u2500\n functions: {\n // Enrich on write: re-fetch the entry, ai-generate a 1-sentence summary +\n // one-word mood, PATCH them back, ingest title+body into the rag index.\n 'on-entry-written': {\n entry: './functions/on-entry-written.ts',\n trigger: { kind: 'cmsHook', collection: 'entries', event: 'beforeWrite' },\n scopes: ['cms:read', 'cms:write', 'ai:write', 'rag:write'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // \"Ask your journal\": POST /v1/fn/ask-journal { question } \u2192 a grounded\n // answer with citations via POST /v1/rag/answer.\n 'ask-journal': {\n entry: './functions/ask-journal.ts',\n trigger: { kind: 'http' },\n scopes: ['rag:write'],\n },\n // Weekly digest: every Monday 08:00 UTC, list the week's entries and send\n // each writer a notifications digest.\n 'weekly-digest': {\n entry: './functions/weekly-digest.ts',\n trigger: { kind: 'cron', schedule: '0 8 * * 1' },\n scopes: ['cms:read', 'notifications:send'],\n },\n },\n\n // \u2500\u2500 SECRET REFERENCES (never values) \u2014 `vxil secrets set ai/openai_key` \u2500\u2500\n secrets: {\n openai_key: { feature: 'ai', description: 'OpenAI API key (BYO provider, envelope-encrypted)' },\n },\n\n seed: {\n cms: [\n {\n collection: 'entries',\n items: [\n {\n title: 'Welcome to your AI journal',\n body: 'Write anything. On every save, a function summarizes the entry, names its mood, and indexes it \u2014 then you can literally ask your journal questions.',\n written_at: '2026-07-01T09:00:00Z',\n user_id: 'demo-user',\n tags: ['welcome'],\n },\n ],\n },\n ],\n },\n});\n",
8000
+ "readme": '# AI Journal template\n\nAn AI-powered private journal \u2014 declared end-to-end in one typed `vxil.config.ts`. Every saved entry is\nenriched by a function (one-sentence summary + one-word mood via the `ai` feature) and indexed for retrieval,\nso you can literally *ask your journal* and get grounded, cited answers back.\n\n**What it provisions:**\n- `entries` \u2014 title, body, AI-derived `mood`/`summary`, `written_at`, tags, and `user_id` as the **owner field**\n (a verified end-user only sees their own journal). Lane-A hooks require a title and stamp `written_at`.\n- Features: `cms` + `ai` + `rag` + `vector-search` (rag\'s retrieval leg) + `notifications` + `functions`.\n- Functions: `on-entry-written` (cmsHook: enrich + ingest), `ask-journal` (http: grounded Q&A),\n `weekly-digest` (cron: Monday digest per writer).\n\n**Apply it:**\n\n```bash\nvxil init --template ai-journal\nvxil quickstart --invite <code> # only when the email is new (or `vxil link` an existing tenant)\nvxil push\nvxil gen\n# one-time: create the retrieval index (dimensions/embedder come from config defaults)\ncurl -X POST https://api.vxil.com/v1/search/collections \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"collection":"journal"}\'\n```\n\n**What to learn from this:**\n1. **AI enrichment on write** \u2014 a `cmsHook` function re-fetches the entry by `item_id` (never trusts inline\n fields), calls `POST /v1/ai/generate` (raw-prompt mode), PATCHes `summary`/`mood` back, and latches on\n `summary` so its own write-back never re-enriches.\n2. **Retrieval-augmented "ask your journal"** \u2014 `POST /v1/rag/answer` retrieves top-k from the `journal`\n index and returns the answer *with citations* (`doc_id` = the entry\'s `item_id`); the prompt stays yours.\n3. **BYO AI key via encrypted secrets** \u2014 config carries only the reference (`providers.openaiKeyRef`);\n `vxil secrets set ai/openai_key` stores the value envelope-encrypted, then flip `defaultProvider`/`model`.\n Until then the deterministic `mock` provider (and mock embedder) run the whole loop keyless.\n\n```bash\ncurl -X POST https://api.vxil.com/v1/fn/ask-journal \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"question":"what made me happy this month?"}\'\n```\n\n**Go deeper:** vxil.com/docs/guide/06-feature-catalog (ai, rag) \xB7 vxil.com/docs/guide/08-running-your-code-functions \xB7\nvxil.com/docs/guide/07-validation-and-hooks \xB7 vxil.com/docs/guide/04-data-with-cms (owner-scope) \xB7 `examples/ecommerce/` (a bigger functions saga).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
8001
+ "functions": {
8002
+ "ask-journal.ts": "// ask-journal.ts \u2014 \"ASK YOUR JOURNAL\" (a vxil function, \xA77.3).\n//\n// Trigger: http \u2014 POST /v1/fn/ask-journal { question, user_id? }. Runs ONE\n// retrieval-augmented call: POST /v1/rag/answer over the `journal` index the\n// on-entry-written function keeps fed. rag retrieves top-k chunks from\n// vector-search, grounds the tenant-owned prompt, generates via the ai feature,\n// and returns the answer WITH citations pointing at the exact entries used \u2014\n// this function is a thin, scoped wrapper (rag:write only).\n//\n// In end-user mode the verified principal is propagated automatically into the\n// scoped token, so retrieval is owner-scoped; in server mode an optional\n// `user_id` rides along for per-user metering.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n // http-trigger: the caller's JSON body lands under `payload`.\n payload?: { question?: string; user_id?: string };\n}\ninterface Citation { chunk_id?: string; doc_id?: string; score?: number }\ninterface AnswerRes { data?: { answer?: string; citations?: Citation[]; usage?: Record<string, unknown> } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const rag = env.scoped_jwts?.rag;\n if (!rag) return json({ error: 'missing rag scope' }, 403);\n\n const question = String(env.payload?.question ?? '').trim();\n if (!question) return json({ error: 'question required', example: { question: 'what made me happy last month?' } }, 400);\n\n const res = await fetch(`${base}/v1/rag/answer`, {\n method: 'POST',\n headers: { authorization: `Bearer ${rag}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n query: question.slice(0, 2000),\n collection: 'journal', // = rag config defaultCollection; explicit for clarity\n ...(env.payload?.user_id ? { user_id: env.payload.user_id } : {}),\n }),\n });\n if (!res.ok) {\n // A missing index is NOT a 404 here: rag's retrieve leg wraps a\n // vector-search failure as 502 retrieval_failed and attaches the\n // downstream error under error.upstream (only a 501 passes through),\n // so detect collection_not_found in the BODY, not the status. The\n // index is a one-time setup (see the template README).\n const errBody = (await res.json().catch(() => null)) as\n { error?: { code?: string; upstream?: { code?: string } } } | null;\n const code = errBody?.error?.upstream?.code ?? errBody?.error?.code;\n if (res.status === 404 || code === 'collection_not_found') {\n return json({ error: 'journal index not found', hint: 'POST /v1/search/collections {\"collection\":\"journal\"} once, then write an entry' }, 404);\n }\n return json({ error: 'answer_failed', status: res.status }, 502);\n }\n\n const body = (await res.json()) as AnswerRes;\n return json({\n answer: body.data?.answer ?? '',\n // provenance: which entries grounded the answer (doc_id = the entry's item_id)\n sources: (body.data?.citations ?? []).map((c) => ({ entry_id: c.doc_id, score: c.score })),\n }, 200);\n },\n};\n\n// \u2500\u2500 tiny helper \u2500\u2500\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
8003
+ "on-entry-written.ts": "// on-entry-written.ts \u2014 AI ENRICHMENT ON WRITE (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `entries`. The hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// entry by id (through the edge, tenant-scoped), then:\n// 1. asks the ai feature (POST /v1/ai/generate, raw-prompt mode) for a\n// ONE-sentence summary and a ONE-word mood,\n// 2. PATCHes them back onto the entry (merge-patch; the summary-present LATCH\n// keeps our own write-back from re-enriching \u2014 clear `summary` to redo),\n// 3. ingests title+body into the rag retrieval index (POST /v1/rag/ingest/\n// journal \u2014 the vector-search passthrough) so ask-journal can ground on it.\n// At-least-once delivery is safe to redeliver: the summary latch skips a\n// re-enrich, the PATCH is idempotent by content, and the ingest converges \u2014\n// vector-search upserts by doc_id, so re-ingesting the same entry re-indexes\n// in place rather than duplicating.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\ninterface EntryData { title?: string; body?: string; summary?: string; mood?: string; written_at?: string; user_id?: string }\ninterface Item { data?: { data?: EntryData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const rag = env.scoped_jwts?.rag;\n const itemId = env.payload?.item_id;\n if (env.payload?.collection !== 'entries' || !cms || !ai || !rag || !itemId) {\n return Response.json({ skipped: true });\n }\n\n // Re-fetch the entry (the payload carries only the id \u2014 never trust inline fields).\n const res = await fetch(`${base}/v1/cms/items/entries/${itemId}`, { headers: H(cms) });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const entry = ((await res.json()) as Item).data?.data ?? {};\n if (entry.summary) return Response.json({ skipped: true, reason: 'already enriched' });\n if (!entry.body) return Response.json({ skipped: true, reason: 'no body yet' });\n\n // 1. AI enrichment \u2014 two small raw-prompt generations ({ data: { text } }).\n const text = entry.body.slice(0, 6000);\n const summary = clip(await generate(base, ai,\n `Summarize this journal entry in exactly one sentence, first person:\\n\\n${text}`, 80, entry.user_id), 400);\n const moodRaw = await generate(base, ai,\n `Answer with ONE lowercase word (e.g. joyful, anxious, calm, tired) naming the dominant mood of this journal entry:\\n\\n${text}`, 8, entry.user_id);\n const mood = (moodRaw.trim().split(/\\s+/)[0] ?? '').toLowerCase().replace(/[^a-z-]/g, '').slice(0, 24);\n if (!summary) return Response.json({ skipped: true, reason: 'ai unavailable' });\n\n // 2. PATCH the derived fields back (merge-patch keys; bumps `version`).\n const patch = await fetch(`${base}/v1/cms/items/entries/${itemId}`, {\n method: 'PATCH',\n headers: H(cms),\n body: JSON.stringify({ data: { summary, ...(mood ? { mood } : {}) } }),\n });\n\n // 3. Ingest into the retrieval index (rag \u2192 vector-search passthrough, 202).\n // Idempotent by doc_id: vector-search UPSERTs on (collection, doc_id), so a\n // redelivered hook (or an edited entry) re-indexes in place.\n const ing = await fetch(`${base}/v1/rag/ingest/journal`, {\n method: 'POST',\n headers: H(rag),\n body: JSON.stringify({\n doc_id: itemId,\n ...(entry.user_id ? { user_id: entry.user_id } : {}),\n text: `${entry.title ?? ''}\\n\\n${entry.body}`,\n metadata: { ...(mood ? { mood } : {}), ...(entry.written_at ? { written_at: entry.written_at } : {}) },\n }),\n });\n return Response.json({\n enriched: patch.ok,\n mood,\n ingested: ing.ok,\n // the index is a one-time setup: POST /v1/search/collections {\"collection\":\"journal\"}\n ...(ing.status === 404 ? { hint: 'create the journal index first (see the template README)' } : {}),\n });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n/** One raw-prompt sync generation; '' on any failure (enrichment is best-effort). */\nasync function generate(base: string, jwt: string, prompt: string, maxTokens: number, userId?: string): Promise<string> {\n const r = await fetch(`${base}/v1/ai/generate`, {\n method: 'POST',\n headers: H(jwt),\n body: JSON.stringify({ prompt, max_tokens: maxTokens, ...(userId ? { user_id: userId } : {}) }),\n }).catch(() => null);\n if (!r || !r.ok) return '';\n return String(((await r.json()) as { data?: { text?: string } }).data?.text ?? '');\n}\nconst clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '\u2026' : s);\n",
8004
+ "weekly-digest.ts": "// weekly-digest.ts \u2014 THE WEEKLY DIGEST (a vxil function, \xA77.3).\n//\n// Trigger: cron ('0 8 * * 1' \u2014 Mondays 08:00 UTC, delivered via the jobs\n// schedule the control-plane reconciles per cron binding). Lists the last 7\n// days of entries (written_at rides the t1 index slot, so the $gte range +\n// sort=-written_at are index-served), groups them per writer, and sends each\n// writer ONE notifications digest ({ subject, paragraph } on the built-in\n// 'transactional' template).\n//\n// Delivery notes: notifications resolves user_id against your end users \u2014 a\n// writer with no email fails that ONE send (user_email_missing) and the loop\n// continues. The per-user Idempotency-Key (envelope key + user id) makes the\n// at-least-once cron redelivery never double-send.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n idempotency_key?: string;\n}\ninterface EntryData { title?: string; mood?: string; user_id?: string; written_at?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n if (!cms || !notif) return Response.json({ skipped: true, reason: 'missing cms/notifications scope' });\n\n // 1. the week's entries, newest first (t1-slotted range + sort).\n const since = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();\n const filter = encodeURIComponent(JSON.stringify({ written_at: { $gte: since } }));\n const res = await fetch(`${base}/v1/cms/items/entries?filter=${filter}&sort=-written_at&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ skipped: true, reason: `list ${res.status}` });\n const body = (await res.json()) as { data?: { items?: { id: string; data: EntryData }[] } };\n const items = body.data?.items ?? [];\n\n // 2. group per writer.\n const byUser = new Map<string, EntryData[]>();\n for (const it of items) {\n const uid = it.data.user_id;\n if (!uid) continue;\n const list = byUser.get(uid) ?? [];\n list.push(it.data);\n byUser.set(uid, list);\n }\n\n // 3. one digest send per writer (best-effort per user; the loop never aborts).\n let sent = 0;\n for (const [uid, entries] of byUser) {\n const lines = entries\n .slice(0, 10)\n .map((e) => `\u2022 ${e.title ?? 'Untitled'}${e.mood ? ` (${e.mood})` : ''}`)\n .join('\\n');\n const ok = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n 'idempotency-key': `${env.idempotency_key ?? 'weekly-digest'}:${uid}`,\n },\n body: JSON.stringify({\n user_id: uid,\n template: 'transactional',\n data: {\n subject: `Your journal week \u2014 ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`,\n paragraph: `You wrote ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'} this week:\\n${lines}`,\n },\n }),\n }).then((r) => r.ok).catch(() => false);\n if (ok) sent += 1;\n }\n\n return Response.json({ entries: items.length, writers: byUser.size, sent });\n },\n};\n"
8005
+ }
8006
+ },
8007
+ {
8008
+ "id": "agent-desk",
8009
+ "title": "Agent Desk (mcp \xB7 copilot \xB7 classify \xB7 judge \xB7 rag)",
8010
+ "vertical": "ai",
8011
+ "summary": "The whole AI half on a deliberately small support desk \u2014 forced-label classification on every new ticket, a grounded and cited draft scored by a second judging pass, a knowledge index kept in step with a cms collection, an in-app assistant whose writes are proposed and human-confirmed, the same backend exposed as a narrowed MCP tool set for a least-privilege agent key, and a capability probe that reads the 170-event catalog so an agent can discover what it is able to react to.",
8012
+ "collections": [
8013
+ "tickets",
8014
+ "kb"
8015
+ ],
8016
+ "features": [
8017
+ "ai",
8018
+ "vector-search",
8019
+ "rag",
8020
+ "copilot",
8021
+ "mcp",
8022
+ "webhooks",
8023
+ "cms",
8024
+ "functions"
8025
+ ],
8026
+ "hasFunctions": true,
8027
+ "byoKeys": [
8028
+ "vxil_read_key"
8029
+ ],
8030
+ "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",
8031
+ "readme": '# Agent Desk (ai)\n\nThe whole AI half of vxil on a deliberately small support desk \u2014 two collections, three functions,\nand one idea per feature. If you have been trying to work out where `ai`, `rag`, `vector-search`,\n`copilot` and `mcp` differ, this is the blueprint that answers it by making each one do exactly its\nown job.\n\n```bash\nvxil init --template agent-desk\nvxil quickstart # or `vxil link <slug>`\nprintf \'%s\' "$READ_KEY" | vxil secrets set functions/vxil_read_key\nvxil push # collections + the three functions\n```\n\nEverything runs on the deterministic **`mock`** model provider, so the walkthrough below is\nreproducible with no provider account and no spend. Swapping in a real model is one config line and\none secret \u2014 nothing else in this blueprint changes.\n\n`vxil_read_key` is a key **of this same backend** carrying only `features:read` and `webhooks:read`;\nthe capability probe uses it (dashboard \u2192 API keys \u2192 create, tick those two and nothing else).\n\n## One idea per feature\n\n| Feature | The one thing it does here | Why it is not one of the others |\n|---|---|---|\n| `ai` classify | pick exactly one label from a fixed set | a chat prompt can return a paragraph; a classifier cannot |\n| `ai` judge | score a draft as an integer on a fixed scale | the model that writes is not the authority on whether the writing is good |\n| `vector-search` | hold the knowledge index, synced from `kb` | retrieval, not generation \u2014 no prompt lives here |\n| `rag` | answer **only** from what was retrieved, with citations | the pipeline; the *prompt* is a template you own |\n| `copilot` | the in-app assistant: propose a write, a human confirms | it is a composition over the four above, not a fifth model |\n| `mcp` | the same backend, as tools, narrowed per key | an agent\'s *interface*, not an agent |\n| `functions` | the deterministic steps around the model calls | the parts that must not be creative |\n\n## Prompts are yours, not config\n\n`rag.defaultTemplate: \'support-answer\'` names a **prompt template**, which is a versioned row you\ncreate over the API \u2014 deliberately not a config leaf, because a prompt is the part of the product\nyou iterate on hourly. Create it before the first answer:\n\n```bash\nvxil api POST /v1/ai/templates --data \'{\n "template": "support-answer",\n "system": "You are a support agent. Answer ONLY from the context. If the context does not contain the answer, say you do not know.",\n "user": "Context:\\n{{context}}\\n\\nCustomer question:\\n{{query}}\\n\\nWrite a short, direct reply."\n}\'\n# 201 { "data": { "template": "support-answer", "version": 1 } }\n```\n\nRe-POST the same name and you get version 2 \u2014 old versions stay pinnable. `{{query}}` and\n`{{context}}` are what the retrieval step fills in. **A grounded answer with no template is a 404**,\nso this is step zero, not an optional flourish.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `ai:read ai:write rag:read rag:write vector-search:read\nvector-search:write cms:read cms:write copilot:read copilot:write webhooks:read functions:invoke\nfeatures:read`.\n\n**1. The index.** The `kb` cms collection is what you author in; the `kb` vector collection is what\nretrieval reads. Create the index, then push an article into it:\n\n```bash\nvxil api POST /v1/search/collections --data \'{"collection":"kb","dimensions":1536}\'\n# 201 { "data": { "collection": "kb", "dimensions": 1536, "backend": "\u2026" } }\n# (`vector-search.sync` also reconciles one scheduled job per entry \u2014 you can see it in\n# `GET /v1/jobs/schedules` as `vs-sync:cms~kb~kb`, on the cron you declared.)\n\nvxil api POST /v1/rag/ingest/kb --data \'{\n "doc_id": "how-refunds-work",\n "text": "A refund is issued to the original payment method within 14 days of purchase. Ask the customer for the order id, confirm the purchase date, then issue the refund from the billing screen. Refunds are not available after 14 days.",\n "metadata": { "topic": "billing" }\n}\'\n# 202 { "data": { "doc_id": "how-refunds-work", "status": "indexed", "chunks": 1, "embedding_tokens": \u2026 } }\n```\n\n`POST /v1/rag/ingest/{collection}` is a convenience: a key holding only `rag:write` can fill the\nindex without also holding a vector-search scope.\n\nYou do not have to remember to do that twice, though \u2014 `vector-search.sync` in `vxil.config.ts`\ndeclares the `kb` cms collection as a source, so published articles are embedded on a schedule and\nthe index never silently drifts from the content. The direct ingest above just saves you the wait.\n\n**2. Classification, on every new ticket.** Create one and watch the hook:\n\n```bash\nvxil api POST /v1/cms/items/tickets --data \'{"data":{"subject":"Billing: charged twice this month","requester":"u_ana","state":"open","body":"My card was charged twice on the 3rd. Can I get one of them back?"}}\'\n# 201 { "data": { "item_id": "itm_\u2026", \u2026 } }\n\n# a moment later\nvxil api GET /v1/cms/items/tickets/itm_\u2026\n# 200 \u2026 "data": { "subject": "Billing: charged twice this month", "category": "billing", "state": "open", \u2026 }\n```\n\n`triage-ticket` fired on the write, re-fetched the row (a hook delivery carries ids, not the\ndocument), and asked for a **forced-label verdict**:\n\n```bash\nvxil api POST /v1/ai/classify --data \'{"input":"Billing: charged twice this month","labels":["billing","bug","how_to","other"]}\'\n# 200 { "data": { "generation_id": "gen_\u2026", "label": "billing", "confidence": 0.9,\n# "rationale": "\u2026", "usage": { \u2026 }, "cached": false } }\n```\n\nThe label set is part of the request, so the answer is constrained to it by the schema \u2014 the model\ncannot invent a fifth category or reply with a sentence. The function is also idempotent by\ninspection: a ticket that already has a `category` is skipped, because hook delivery is\nat-least-once and a redelivery should not cost another model call.\n\n**3. A grounded, cited draft \u2014 and a second opinion on it.** Press the record\'s button:\n\n```bash\nvxil api POST /v1/cms/items/tickets/itm_\u2026/actions/draft_reply\n# 200 { "data": { "collection": "tickets", "item_id": "itm_\u2026", "action": "draft_reply",\n# "fn": "draft-reply",\n# "result": { "draft": "\u2026", "score": 10, "verdict": "pass",\n# "citations": [ { "chunk_id": "how-refunds-work#0",\n# "doc_id": "how-refunds-work", "score": 0.0164 } ],\n# "written": true } } }\n```\n\nTwo calls happened inside, and the split is the lesson:\n\n```bash\nvxil api POST /v1/rag/answer --data \'{"query":"My card was charged twice. Can I get one back?","collection":"kb","top_k":5,"stream":false}\'\n# 200 { "data": { "answer": "\u2026",\n# "citations": [ { "chunk_id": "how-refunds-work#0", "doc_id": "how-refunds-work", "score": 0.0164 } ],\n# "usage": { "retrieval_ms": 21, "retrieved": 1, "used": 1, \u2026 }, "finish": "stop" } }\n\nvxil api POST /v1/ai/judge --data \'{\n "input": "My card was charged twice. Can I get one back?",\n "candidate": "Refunds go back to the original payment method within 14 days of purchase.",\n "criteria": [ { "name": "answers the question asked", "weight": 2 },\n { "name": "is supported by the cited text", "weight": 2 } ],\n "scale": { "min": 0, "max": 10 } }\'\n# 200 { "data": { "generation_id": "gen_\u2026", "score": 10, "verdict": "pass", "rationale": "\u2026", \u2026 } }\n```\n\n`stream: false` is load-bearing. With streaming on (the default), this route answers with a\n`generation_id`, a channel, a token and a `resume_path` for a browser to attach to \u2014 the citations\narrive immediately and the text streams. A server-side step wants the finished text, so it asks for\nit. Getting this wrong is a silent empty draft, not an error.\n\n`citations` are the chunks that actually **survived the context budget** \u2014 not everything retrieved.\nThat distinction is what makes them auditable: every sentence in the draft is traceable to text in\nthe list. And the score is a forced integer on a fixed scale, so drafts are comparable to each\nother rather than each getting its own adjective.\n\nNothing was sent to a customer. The action writes `draft` and `draft_score` onto the ticket and\nstops \u2014 the last step is a person.\n\n**4. The assistant: propose, then confirm.** The copilot answers from the same index and, when a\nturn would *write*, stops and asks:\n\n```bash\nvxil api POST /v1/copilot/desk/messages --data \'{"user_id":"u_agent","message":"What is our refund window?"}\'\n# 200 { "data": { "conversation_id": "cnv_\u2026", "message_id": "msg_\u2026",\n# "answer": "Refunds are available within 14 days of purchase\u2026",\n# "action_status": "none", "citations": [ \u2026 ], \u2026 } }\n\nvxil api POST /v1/copilot/desk/messages --data \'{"conversation_id":"cnv_\u2026","user_id":"u_agent","message":"Open a ticket for Ana about the double charge."}\'\n# 200 { "data": { "message_id": "msg_\u2026", "action_status": "proposed",\n# "proposal": { "message_id": "msg_\u2026", "tool": "cms_create_item",\n# "args": { "collection": "tickets", "data": { "subject": "\u2026", \u2026 } },\n# "feature": "cms", "proposed_at": "\u2026", "require_confirm": true,\n# "confirm_path": "/v1/copilot/conversations/cnv_\u2026/actions/msg_\u2026/confirm" },\n# \u2026 } }\n```\n\nOn the **mock** provider that second turn answers `action_status: "none"` \u2014 the mock does not decide\nto call a tool on its own. Steer it with the marker the platform\'s own end-to-end tests use, and the\nturn produces a real proposal you can confirm:\n\n```text\nOpen a ticket for Ana about the double charge.\n[[tool_call:cms_create_item {"collection":"tickets","data":{"subject":"Double charge for Ana","requester":"u_ana","state":"open"}}]]\n```\n\nNothing has been written yet. The proposal names the tool and the exact arguments, and hands you\nthe confirm path. Commit it:\n\n```bash\nvxil api POST /v1/copilot/conversations/cnv_\u2026/actions/msg_\u2026/confirm\n# 200 { "data": { "message_id": "msg_\u2026", "proposal_message_id": "msg_\u2026", "action_status": "confirmed",\n# "confirmed_at": "\u2026", "result": { "data": { "item_id": "itm_\u2026", \u2026 } } } }\n```\n\nConfirm takes **no body** \u2014 the ids in the path are the whole request, which is what makes the\nlatch tamper-proof: you cannot confirm a *different* write than the one you were shown. Call it\ntwice and the second answers `already: true`. Wait fifteen minutes and it is\n`410 proposal_expired`. And the permission check runs **again at confirm time**, so a scope revoked\nbetween proposal and confirm stops the write.\n\nThe keys under `copilot.agents.desk.actions.allow` are tool names from the catalog \u2014\n`cms_query_items` (a read, run inline) and `cms_create_item` / `cms_run_item_action` (writes,\nproposed). An unknown key there is inert, never invented.\n\n**5. The same backend, as tools.** Point an agent at it:\n\n```bash\nvxil mcp install --client claude --scopes features:read,cms:read,ai:write,rag:read,vector-search:read,webhooks:read\n```\n\nThat mints a dedicated, `agent`-tagged, revocable key and writes the MCP server entry for your\nclient. Three layers decide what the agent can do, and they compose:\n\n1. **`mcp.exposureLevel: \'custom\'` + `allowToolList`** in this config \u2014 the tenant-wide surface.\n2. **the key\'s scopes** \u2014 what the underlying REST route will accept.\n3. **the key\'s `allowed_tools` / `denied_tools`** \u2014 a per-key narrowing on top, editable after\n minting without rotating the key.\n\n`features:read` is load-bearing: without it the policy probe (`GET /v1/config/mcp`) is refused and\nthe agent sees **zero** tools with no obvious error. Mint least privilege, but not less than that.\n\n**6. What can I react to here?** The last function answers the question an agent always has to ask\na human today:\n\n```bash\nvxil functions invoke agent-capabilities\n# { "catalog_events": 170,\n# "enabled_features": [ "ai", "cms", "copilot", "functions", "mcp", "rag", "vector-search", "webhooks" ],\n# "reactable_prefixes": [ { "prefix": "cms.item.", "count": \u2026 }, { "prefix": "ai.", "count": \u2026 }, \u2026 ],\n# "failure_events": [ "job.dead_lettered", "jobs.schedule.missed",\n# "webhooks.delivery.dead_lettered", \u2026 ],\n# "how_to_subscribe": "POST /v1/webhooks/subscriptions \u2026" }\n```\n\nIt reads `GET /v1/webhooks/events/catalog` \u2014 the machine-readable list of every lifecycle and\nfailure event the platform writes, with a prefix roll-up \u2014 and folds it against the features this\nbackend actually has on. The agent can call the catalog itself, too: `webhooks_event_catalog` is in\nthe tool list above, which is the difference between an agent that *has* tools and one that can\n**discover** what the system will tell it.\n\nActing on that discovery is one call with a key that carries `webhooks:write` \u2014 deliberately not\nthe read-only key this function holds:\n\n```bash\nvxil api POST /v1/webhooks/subscriptions --data \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["cms.item.","ai."]}\'\n```\n\n## What to learn from this\n\n- **Forcing the shape is the feature.** Classify returns one of *your* labels; judge returns an\n integer in *your* range. Most "the model went off the rails" problems are a missing schema, not a\n missing instruction.\n- **Two passes beat one long prompt.** Writing and evaluating are different jobs, and separating\n them gives you a number you can threshold, chart and regress against.\n- **Grounding is a pipeline, not a prompt trick.** Retrieval, a context budget, and citations of\n the chunks that survived it \u2014 the answer is auditable because the pipeline kept the receipts.\n- **Propose \u2192 confirm is where agent safety actually lives.** Not in a system prompt asking the\n model to be careful: in a latch that persists the exact arguments, re-checks permission at commit\n time, expires, and executes at most once.\n- **Least privilege for an agent is three layers, not one.** The tenant\'s exposure list, the key\'s\n scopes, and the key\'s per-tool narrowing \u2014 each can be tightened without touching the others.\n- **An agent should be able to ask the backend what it can do.** A tool catalog and an event\n catalog are both machine-readable for the same reason: the alternative is a prompt that goes stale\n the next time you ship.\n\n**Pairs with:** `templates/ai-journal/` (enrichment on write, and asking your own data questions)\nand `templates/helpdesk/` (the same desk without the AI half).\n',
7947
8032
  "functions": {
7948
- "ask-journal.ts": "// ask-journal.ts \u2014 \"ASK YOUR JOURNAL\" (a vxil function, \xA77.3).\n//\n// Trigger: http \u2014 POST /v1/fn/ask-journal { question, user_id? }. Runs ONE\n// retrieval-augmented call: POST /v1/rag/answer over the `journal` index the\n// on-entry-written function keeps fed. rag retrieves top-k chunks from\n// vector-search, grounds the tenant-owned prompt, generates via the ai feature,\n// and returns the answer WITH citations pointing at the exact entries used \u2014\n// this function is a thin, scoped wrapper (rag:write only).\n//\n// In end-user mode the verified principal is propagated automatically into the\n// scoped token, so retrieval is owner-scoped; in server mode an optional\n// `user_id` rides along for per-user metering.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n // http-trigger: the caller's JSON body lands under `payload`.\n payload?: { question?: string; user_id?: string };\n}\ninterface Citation { chunk_id?: string; doc_id?: string; score?: number }\ninterface AnswerRes { data?: { answer?: string; citations?: Citation[]; usage?: Record<string, unknown> } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.org';\n const rag = env.scoped_jwts?.rag;\n if (!rag) return json({ error: 'missing rag scope' }, 403);\n\n const question = String(env.payload?.question ?? '').trim();\n if (!question) return json({ error: 'question required', example: { question: 'what made me happy last month?' } }, 400);\n\n const res = await fetch(`${base}/v1/rag/answer`, {\n method: 'POST',\n headers: { authorization: `Bearer ${rag}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n query: question.slice(0, 2000),\n collection: 'journal', // = rag config defaultCollection; explicit for clarity\n ...(env.payload?.user_id ? { user_id: env.payload.user_id } : {}),\n }),\n });\n if (!res.ok) {\n // A missing index is NOT a 404 here: rag's retrieve leg wraps a\n // vector-search failure as 502 retrieval_failed and attaches the\n // downstream error under error.upstream (only a 501 passes through),\n // so detect collection_not_found in the BODY, not the status. The\n // index is a one-time setup (see the template README).\n const errBody = (await res.json().catch(() => null)) as\n { error?: { code?: string; upstream?: { code?: string } } } | null;\n const code = errBody?.error?.upstream?.code ?? errBody?.error?.code;\n if (res.status === 404 || code === 'collection_not_found') {\n return json({ error: 'journal index not found', hint: 'POST /v1/search/collections {\"collection\":\"journal\"} once, then write an entry' }, 404);\n }\n return json({ error: 'answer_failed', status: res.status }, 502);\n }\n\n const body = (await res.json()) as AnswerRes;\n return json({\n answer: body.data?.answer ?? '',\n // provenance: which entries grounded the answer (doc_id = the entry's item_id)\n sources: (body.data?.citations ?? []).map((c) => ({ entry_id: c.doc_id, score: c.score })),\n }, 200);\n },\n};\n\n// \u2500\u2500 tiny helper \u2500\u2500\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
7949
- "on-entry-written.ts": "// on-entry-written.ts \u2014 AI ENRICHMENT ON WRITE (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `entries`. The hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// entry by id (through the edge, tenant-scoped), then:\n// 1. asks the ai feature (POST /v1/ai/generate, raw-prompt mode) for a\n// ONE-sentence summary and a ONE-word mood,\n// 2. PATCHes them back onto the entry (merge-patch; the summary-present LATCH\n// keeps our own write-back from re-enriching \u2014 clear `summary` to redo),\n// 3. ingests title+body into the rag retrieval index (POST /v1/rag/ingest/\n// journal \u2014 the vector-search passthrough) so ask-journal can ground on it.\n// At-least-once delivery is safe to redeliver: the summary latch skips a\n// re-enrich, the PATCH is idempotent by content, and the ingest converges \u2014\n// vector-search upserts by doc_id, so re-ingesting the same entry re-indexes\n// in place rather than duplicating.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\ninterface EntryData { title?: string; body?: string; summary?: string; mood?: string; written_at?: string; user_id?: string }\ninterface Item { data?: { data?: EntryData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.org';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const rag = env.scoped_jwts?.rag;\n const itemId = env.payload?.item_id;\n if (env.payload?.collection !== 'entries' || !cms || !ai || !rag || !itemId) {\n return Response.json({ skipped: true });\n }\n\n // Re-fetch the entry (the payload carries only the id \u2014 never trust inline fields).\n const res = await fetch(`${base}/v1/cms/items/entries/${itemId}`, { headers: H(cms) });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const entry = ((await res.json()) as Item).data?.data ?? {};\n if (entry.summary) return Response.json({ skipped: true, reason: 'already enriched' });\n if (!entry.body) return Response.json({ skipped: true, reason: 'no body yet' });\n\n // 1. AI enrichment \u2014 two small raw-prompt generations ({ data: { text } }).\n const text = entry.body.slice(0, 6000);\n const summary = clip(await generate(base, ai,\n `Summarize this journal entry in exactly one sentence, first person:\\n\\n${text}`, 80, entry.user_id), 400);\n const moodRaw = await generate(base, ai,\n `Answer with ONE lowercase word (e.g. joyful, anxious, calm, tired) naming the dominant mood of this journal entry:\\n\\n${text}`, 8, entry.user_id);\n const mood = (moodRaw.trim().split(/\\s+/)[0] ?? '').toLowerCase().replace(/[^a-z-]/g, '').slice(0, 24);\n if (!summary) return Response.json({ skipped: true, reason: 'ai unavailable' });\n\n // 2. PATCH the derived fields back (merge-patch keys; bumps `version`).\n const patch = await fetch(`${base}/v1/cms/items/entries/${itemId}`, {\n method: 'PATCH',\n headers: H(cms),\n body: JSON.stringify({ data: { summary, ...(mood ? { mood } : {}) } }),\n });\n\n // 3. Ingest into the retrieval index (rag \u2192 vector-search passthrough, 202).\n // Idempotent by doc_id: vector-search UPSERTs on (collection, doc_id), so a\n // redelivered hook (or an edited entry) re-indexes in place.\n const ing = await fetch(`${base}/v1/rag/ingest/journal`, {\n method: 'POST',\n headers: H(rag),\n body: JSON.stringify({\n doc_id: itemId,\n ...(entry.user_id ? { user_id: entry.user_id } : {}),\n text: `${entry.title ?? ''}\\n\\n${entry.body}`,\n metadata: { ...(mood ? { mood } : {}), ...(entry.written_at ? { written_at: entry.written_at } : {}) },\n }),\n });\n return Response.json({\n enriched: patch.ok,\n mood,\n ingested: ing.ok,\n // the index is a one-time setup: POST /v1/search/collections {\"collection\":\"journal\"}\n ...(ing.status === 404 ? { hint: 'create the journal index first (see the template README)' } : {}),\n });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n/** One raw-prompt sync generation; '' on any failure (enrichment is best-effort). */\nasync function generate(base: string, jwt: string, prompt: string, maxTokens: number, userId?: string): Promise<string> {\n const r = await fetch(`${base}/v1/ai/generate`, {\n method: 'POST',\n headers: H(jwt),\n body: JSON.stringify({ prompt, max_tokens: maxTokens, ...(userId ? { user_id: userId } : {}) }),\n }).catch(() => null);\n if (!r || !r.ok) return '';\n return String(((await r.json()) as { data?: { text?: string } }).data?.text ?? '');\n}\nconst clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '\u2026' : s);\n",
7950
- "weekly-digest.ts": "// weekly-digest.ts \u2014 THE WEEKLY DIGEST (a vxil function, \xA77.3).\n//\n// Trigger: cron ('0 8 * * 1' \u2014 Mondays 08:00 UTC, delivered via the jobs\n// schedule the control-plane reconciles per cron binding). Lists the last 7\n// days of entries (written_at rides the t1 index slot, so the $gte range +\n// sort=-written_at are index-served), groups them per writer, and sends each\n// writer ONE notifications digest ({ subject, paragraph } on the built-in\n// 'transactional' template).\n//\n// Delivery notes: notifications resolves user_id against your end users \u2014 a\n// writer with no email fails that ONE send (user_email_missing) and the loop\n// continues. The per-user Idempotency-Key (envelope key + user id) makes the\n// at-least-once cron redelivery never double-send.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n idempotency_key?: string;\n}\ninterface EntryData { title?: string; mood?: string; user_id?: string; written_at?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.org';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n if (!cms || !notif) return Response.json({ skipped: true, reason: 'missing cms/notifications scope' });\n\n // 1. the week's entries, newest first (t1-slotted range + sort).\n const since = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();\n const filter = encodeURIComponent(JSON.stringify({ written_at: { $gte: since } }));\n const res = await fetch(`${base}/v1/cms/items/entries?filter=${filter}&sort=-written_at&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ skipped: true, reason: `list ${res.status}` });\n const body = (await res.json()) as { data?: { items?: { id: string; data: EntryData }[] } };\n const items = body.data?.items ?? [];\n\n // 2. group per writer.\n const byUser = new Map<string, EntryData[]>();\n for (const it of items) {\n const uid = it.data.user_id;\n if (!uid) continue;\n const list = byUser.get(uid) ?? [];\n list.push(it.data);\n byUser.set(uid, list);\n }\n\n // 3. one digest send per writer (best-effort per user; the loop never aborts).\n let sent = 0;\n for (const [uid, entries] of byUser) {\n const lines = entries\n .slice(0, 10)\n .map((e) => `\u2022 ${e.title ?? 'Untitled'}${e.mood ? ` (${e.mood})` : ''}`)\n .join('\\n');\n const ok = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n 'idempotency-key': `${env.idempotency_key ?? 'weekly-digest'}:${uid}`,\n },\n body: JSON.stringify({\n user_id: uid,\n template: 'transactional',\n data: {\n subject: `Your journal week \u2014 ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`,\n paragraph: `You wrote ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'} this week:\\n${lines}`,\n },\n }),\n }).then((r) => r.ok).catch(() => false);\n if (ok) sent += 1;\n }\n\n return Response.json({ entries: items.length, writers: byUser.size, sent });\n },\n};\n"
8033
+ "agent-capabilities.ts": "// agent-capabilities.ts \u2014 \"WHAT CAN I REACT TO HERE?\" (a vxil function).\n//\n// Trigger: http. An agent (or your own onboarding screen) calls this once and\n// learns, from the backend itself, what this workspace can emit \u2014 instead of a\n// human pasting a list into a prompt that goes stale the next release.\n//\n// Two reads, folded together:\n// \u2022 `GET /v1/webhooks/events/catalog` \u2014 the machine-readable list of every\n// lifecycle and failure event the platform writes, with a `prefixes` roll-up\n// you can subscribe to directly.\n// \u2022 `GET /v1/features` \u2014 which features THIS backend actually has on.\n// The answer is the intersection: the prefixes worth subscribing to here.\n//\n// WHY A KEY AND NOT THE FUNCTION'S OWN CALLBACK: a function's scoped callback\n// covers the feature APIs (cms, ai, rag, \u2026). The event catalog and the feature\n// list are platform reads, so this uses the narrowest key that can reach them \u2014\n// one holding only `features:read` and `webhooks:read`, stored as a secret,\n// resolved per invocation, revocable in one click without a redeploy.\n//\n// To actually SUBSCRIBE, POST to /v1/webhooks/subscriptions with\n// { target_url, event_prefixes } using a key that carries `webhooks:write` \u2014\n// deliberately NOT this one (see the README).\n\n/** prefix segment \u2192 the feature key it belongs to, where the names differ. */\nconst PREFIX_FEATURE: Record<string, string> = {\n job: 'jobs', jobs: 'jobs', user: 'auth', auth: 'auth', session: 'auth',\n org: 'orgs', orgs: 'orgs', rate_limits: 'rate-limits', feeds: 'activity-feed',\n 'vector-search': 'vector-search', functions: 'functions',\n};\n\ninterface Env {\n vxil_base?: string;\n secrets?: Record<string, string>;\n payload?: { all?: boolean };\n}\ninterface CatalogEvent { name?: string; feature?: string; level?: string }\ninterface Prefix { prefix?: string; count?: number }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const key = env.secrets?.vxil_read_key;\n if (!key) {\n return Response.json(\n { error: 'missing_secret', message: 'set the vxil_read_key secret first' },\n { status: 503 },\n );\n }\n const h = { authorization: `Bearer ${key}` };\n\n const [catRes, featRes] = await Promise.all([\n fetch(`${base}/v1/webhooks/events/catalog`, { headers: h }),\n fetch(`${base}/v1/features`, { headers: h }),\n ]);\n if (!catRes.ok) {\n return Response.json({ error: 'catalog_unavailable', status: catRes.status }, { status: 502 });\n }\n const cat = ((await catRes.json()) as {\n data?: { count?: number; events?: CatalogEvent[]; prefixes?: Prefix[] };\n }).data ?? {};\n const enabled = new Set(\n featRes.ok\n ? ((await featRes.json()) as { data?: { features?: string[] } }).data?.features ?? []\n : [],\n );\n\n const all = env.payload?.all === true;\n const prefixes = (cat.prefixes ?? []).filter((p) => {\n if (all || enabled.size === 0) return true;\n const head = String(p.prefix ?? '').replace(/\\.$/, '');\n return enabled.has(PREFIX_FEATURE[head] ?? head);\n });\n\n // The failure half is the half worth wiring first: it is what tells you the\n // backend is unhappy before a customer does.\n const failures = (cat.events ?? [])\n .filter((e) => e.level === 'failure')\n .map((e) => e.name)\n .filter((n): n is string => typeof n === 'string')\n .sort();\n\n return Response.json({\n catalog_events: cat.count ?? (cat.events ?? []).length,\n enabled_features: [...enabled].sort(),\n reactable_prefixes: prefixes,\n failure_events: failures,\n how_to_subscribe:\n 'POST /v1/webhooks/subscriptions { \"target_url\": \"https://\u2026\", \"event_prefixes\": [\"job.\", \"cms.item.\"] } '\n + 'with a key carrying webhooks:write',\n });\n },\n};\n",
8034
+ "draft-reply.ts": "// draft-reply.ts \u2014 RETRIEVE \u2192 GROUND \u2192 SCORE (a vxil function).\n//\n// Trigger: the per-record action `draft_reply` on `tickets`. The action envelope\n// carries the WHOLE row, so this step needs no re-fetch:\n// { collection, item_id, action, actor, item: { item_id, status, version, data } }\n//\n// Three calls, three jobs, in order:\n// 1. `POST /v1/rag/answer` \u2014 retrieve from the `kb` index and answer ONLY from\n// what came back, returning the chunks it used as citations. A grounded\n// answer you can audit beats a confident one you cannot.\n// 2. `POST /v1/ai/judge` \u2014 score that draft against a rubric, as an integer on\n// a fixed scale. The model that writes is not the authority on whether the\n// writing is good; a second, schema-forced pass is.\n// 3. one PATCH \u2014 persist the draft + its score so a human decides what to send.\n//\n// Nothing here sends anything to a customer. The last step is always a person.\n\nconst CRITERIA = [\n { name: 'answers the question asked', weight: 2 },\n { name: 'is supported by the cited knowledge-base text', weight: 2 },\n { name: 'is concise and free of speculation', weight: 1 },\n];\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: {\n collection?: string;\n item_id?: string;\n item?: { data?: { subject?: string; body?: string; category?: string } };\n };\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const rag = env.scoped_jwts?.rag;\n const ai = env.scoped_jwts?.ai;\n const itemId = env.payload?.item_id;\n if (!cms || !rag || !ai || !itemId || env.payload?.collection !== 'tickets') {\n return Response.json({ error: 'bad_request', message: 'not a tickets action' }, { status: 400 });\n }\n\n const t = env.payload?.item?.data ?? {};\n const question = `${t.subject ?? ''}\\n\\n${t.body ?? ''}`.trim();\n if (!question) return Response.json({ error: 'empty_ticket' }, { status: 422 });\n\n // 1. GROUNDED ANSWER. `template` falls back to the rag config's\n // `defaultTemplate`, so the call stays this short. `stream: false` is\n // load-bearing: with streaming enabled (the default) this route answers\n // with a channel + resume path for a browser to attach to, NOT the text.\n // A server-side step wants the text, so it says so.\n const answered = await fetch(`${base}/v1/rag/answer`, {\n method: 'POST',\n headers: { authorization: `Bearer ${rag}`, 'content-type': 'application/json' },\n body: JSON.stringify({ query: question, collection: 'kb', top_k: 5, stream: false }),\n });\n if (!answered.ok) {\n const detail = await answered.text();\n return Response.json(\n { error: 'retrieval_failed', status: answered.status, detail: detail.slice(0, 300) },\n { status: 502 },\n );\n }\n const grounded = ((await answered.json()) as {\n data?: { answer?: string; citations?: unknown[]; usage?: unknown };\n }).data ?? {};\n const draft = String(grounded.answer ?? '').trim();\n const citations = Array.isArray(grounded.citations) ? grounded.citations : [];\n if (!draft) return Response.json({ error: 'empty_draft' }, { status: 502 });\n\n // 2. SCORE IT. A forced integer on a fixed scale \u2014 comparable across drafts,\n // unlike \"this looks good\".\n const scored = await fetch(`${base}/v1/ai/judge`, {\n method: 'POST',\n headers: { authorization: `Bearer ${ai}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n input: question,\n candidate: draft,\n criteria: CRITERIA,\n scale: { min: 0, max: 10 },\n }),\n });\n const verdict = scored.ok\n ? ((await scored.json()) as { data?: { score?: number; verdict?: string; rationale?: string } }).data ?? {}\n : {};\n const score = typeof verdict.score === 'number' ? Math.round(verdict.score) : null;\n\n // 3. PERSIST. A human reads it, edits it, and decides whether it is sent.\n const patch = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: { draft, ...(score === null ? {} : { draft_score: score }), state: 'drafted' },\n }),\n });\n\n return Response.json({\n item_id: itemId,\n draft,\n score,\n verdict: verdict.verdict ?? null,\n rationale: verdict.rationale ?? null,\n citations,\n written: patch.ok,\n });\n },\n};\n",
8035
+ "triage-ticket.ts": "// triage-ticket.ts \u2014 CLASSIFY EVERY NEW TICKET (a vxil function).\n//\n// Trigger: cmsHook on `tickets`. A hook delivery carries ids, not the row\n// ({ event, collection, item_id }), so the function RE-FETCHES the ticket\n// rather than trusting inline fields \u2014 and delivery is at-least-once, so it\n// skips a ticket that already carries a category instead of re-billing a model\n// call on a redelivery.\n//\n// The one model call is a FORCED-LABEL verdict: `POST /v1/ai/classify` takes the\n// label set and returns exactly one of them (plus a confidence and a one-line\n// rationale). That is the difference between a classifier and a chat prompt \u2014\n// the answer cannot be a paragraph, a new label, or an apology.\n\nconst LABELS = ['billing', 'bug', 'how_to', 'other'];\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\ninterface TicketData { subject?: string; body?: string; category?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const itemId = env.payload?.item_id;\n if (!cms || !ai || !itemId || env.payload?.collection !== 'tickets') {\n return Response.json({ skipped: true, reason: 'not a tickets hook' });\n }\n // The cms.item.* subscription also delivers updates \u2014 only triage a create.\n if (!String(env.payload?.event ?? '').endsWith('.created')) {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n\n const read = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!read.ok) return Response.json({ skipped: true, reason: `fetch ${read.status}` });\n const ticket = ((await read.json()) as { data?: { data?: TicketData } }).data?.data ?? {};\n // Already triaged \u21D2 this is a redelivery. Do nothing (and pay for nothing).\n if (ticket.category) {\n return Response.json({ skipped: true, reason: 'already triaged', category: ticket.category });\n }\n\n const input = `${ticket.subject ?? ''}\\n\\n${ticket.body ?? ''}`.trim();\n if (!input) return Response.json({ skipped: true, reason: 'empty ticket' });\n\n const verdict = await fetch(`${base}/v1/ai/classify`, {\n method: 'POST',\n headers: { authorization: `Bearer ${ai}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n input,\n labels: LABELS,\n rubric:\n 'billing = money, invoices, refunds or subscriptions. '\n + 'bug = something is broken or behaves incorrectly. '\n + 'how_to = the customer is asking how to do something. '\n + 'other = anything else.',\n }),\n });\n if (!verdict.ok) {\n const detail = await verdict.text();\n return Response.json(\n { error: 'classify_failed', status: verdict.status, detail: detail.slice(0, 300) },\n { status: 502 },\n );\n }\n const v = ((await verdict.json()) as {\n data?: { label?: string; confidence?: number; rationale?: string };\n }).data ?? {};\n const label = LABELS.includes(String(v.label)) ? String(v.label) : 'other';\n\n const patch = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { category: label, state: 'open' } }),\n });\n\n return Response.json({\n item_id: itemId,\n category: label,\n confidence: v.confidence ?? null,\n rationale: v.rationale ?? null,\n written: patch.ok,\n });\n },\n};\n"
7951
8036
  }
7952
8037
  },
7953
8038
  {
@@ -8035,7 +8120,7 @@ export default defineConfig({
8035
8120
  stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' },
8036
8121
  defaults: { currency: 'usd' },
8037
8122
  // The ledger is for STORE CREDIT / gift cards / loyalty (add-only grants +
8038
- // balance-guarded consume) \u2014 populate productMap/tierMap per docs/features/payments.md.
8123
+ // balance-guarded consume) \u2014 populate productMap/tierMap per vxil.com/docs/guide/06-feature-catalog (payments).
8039
8124
  ledger: { productMap: {}, tierMap: {} },
8040
8125
  },
8041
8126
 
@@ -8136,7 +8221,7 @@ export default defineConfig({
8136
8221
  },
8137
8222
  // \u2605 One row per redemption. When you honor a coupon at capture, write the row
8138
8223
  // with a \`guards[]\` count-cap ON THE WRITE BODY \u2014 { lock: 'coupon:'+code,
8139
- // guards: [{ filter: { code }, max: max_uses }] } (docs/features/cms.md \xA710;
8224
+ // guards: [{ filter: { code }, max: max_uses }] } (vxil.com/docs/api;
8140
8225
  // guard/guards ride the request, they are NOT config) \u2014 so a coupon can never
8141
8226
  // over-redeem under concurrency. (Not wired into checkout.ts \u2014 add it there.)
8142
8227
  coupon_redemptions: {
@@ -8214,12 +8299,12 @@ export default defineConfig({
8214
8299
  },
8215
8300
  });
8216
8301
  `,
8217
- "readme": "# Storefront / E-commerce template\n\nA single-seller e-commerce backend \u2014 catalog, variants, owner-scoped carts, coupons, moderated\nreviews, and an exactly-once checkout \u2014 declared in one typed `vxil.config.ts` plus four functions.\nPayments run as a payments integration with **your own Stripe account**: vxil is never in the flow of funds.\n\n**What it provisions:**\n- `products` / `variants` \u2014 the catalog; `variants.stock` is the oversell-safe inventory counter;\n `variants.price_ref` holds the provider price id (e.g. a Stripe Price) the hosted checkout charges by.\n Both are **`public: true`** \u2014 the shopfront (grid + product-detail variants) reads keyless (see below);\n the transactional collections (`carts`/`orders`/`coupons`) are **not** public.\n- `carts` / `cart_items` \u2014 owner-scoped carts per shopper (guest checkout via anonymous auth);\n each line snapshots `unit_price_cents` + `price_ref` at add-to-cart.\n- `orders` \u2014 unique `number` + unique `cart_ref`, with a state-machine hook guarding status transitions.\n- `coupons` / `coupon_redemptions` \u2014 codes plus one row per redemption; cap redemption writes\n with a write-body `guards[]` under one `lock` (the cms \xA710 pattern \u2014 shown in the config, not wired into `checkout.ts`).\n- `reviews` \u2014 owner-scoped, 1\u20135 rating enforced by a hook, moderated via draft/publish.\n- Features: `cms`, `auth`, `payments` (Stripe, BYO keys), `notifications`, `functions`; four functions \u2014\n `checkout` (the saga), `price-cart` (pricing engine), `on-order-paid` (receipt + fulfillment webhook),\n `abandoned-cart` (hourly cron nudge).\n\n**Apply it:**\n\n```bash\nvxil init --template storefront\nvxil quickstart\nvxil secrets set stripe_secret && vxil secrets set stripe_webhook\nvxil push\nvxil seed # demo products + the WELCOME10 coupon\nvxil gen\n```\n\n**What to learn from this:**\n1. **The checkout saga** (`functions/checkout.ts`) \u2014 reserve inventory \u2192 create the order \u2192 open the\n payment, compensating on any failure. All-or-nothing across features, no cross-feature ACID needed.\n Invoke `checkout`/`price-cart` from YOUR backend (server key): with `strictEndUserScope` on, an\n end-user-mode invocation is correctly denied on the shared collections they touch\n (`cart_items`/`variants`/`coupons`) \u2014 inventory is tenant-wide by design.\n2. **Oversell-safety** \u2014 `variants.stock` with `validation: { min: 0 }` makes `PATCH { $inc: { stock: -qty } }`\n a single-statement conditional decrement: exactly one winner under N concurrent buyers.\n3. **Exactly-once placement** \u2014 `orders.cart_ref` is declared `unique: true` in the config (carried by\n `vxil push`, `cms.md` \xA79.3), so N racing checkouts of one cart yield exactly one order \u2014 the rest see\n the `409 unique_violation` and `checkout.ts` answers `already_placed` (idempotent).\n\n```typescript\n// browse the live catalog with a server key (typed SDK)\nconst { items } = await vx.from('products').query({\n filter: { $status: 'published' },\n sort: '-price_cents',\n limit: 25,\n});\n```\n\n4. **The public shopfront \u2014 keyless** (`docs/features/cms.md` \xA716). `products` and `variants` are\n `public: true`, so a static/JAMstack storefront renders the grid and product-detail pages with **no API\n key** \u2014 the edge forces `status = 'published'` and edge-caches the response. Only the write path (cart,\n checkout, admin) needs a key; the transactional collections are never public.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public grid \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'products', { sort: '-price_cents', limit: 24 });\n// a product's sellable variants (price, availability, provider price_ref)\nconst variants = await listCmsPublic('ten_your_tenant_id', 'variants', { filter: { product: productId } });\n```\n\n**Reader UI (optional):** drop [`@vxil/react/comments`](../../packages/react) onto a product page\nfor the `reviews` thread + a file uploader (enable the `comments` feature) \u2014 a pure client-side component\nover the shipped API. It installs via npm into a bundled app (no served `.mjs`).\n\n**Go deeper:** [`examples/ecommerce`](../../examples/ecommerce) is the fully-annotated deep version of this\nblueprint; [`docs/features/cms.md`](../../docs/features/cms.md) (\xA77 hooks, \xA79.3 `$inc`, \xA710 `lock`/`guards[]`,\n**\xA716 public delivery**), [`docs/features/payments.md`](../../docs/features/payments.md),\n[`docs/features/functions.md`](../../docs/features/functions.md), and `templates/catalog/` (a content-only\npublic product grid).\n",
8302
+ "readme": "# Storefront / E-commerce template\n\nA single-seller e-commerce backend \u2014 catalog, variants, owner-scoped carts, coupons, moderated\nreviews, and an exactly-once checkout \u2014 declared in one typed `vxil.config.ts` plus four functions.\nPayments run as a payments integration with **your own Stripe account**: vxil is never in the flow of funds.\n\n**What it provisions:**\n- `products` / `variants` \u2014 the catalog; `variants.stock` is the oversell-safe inventory counter;\n `variants.price_ref` holds the provider price id (e.g. a Stripe Price) the hosted checkout charges by.\n Both are **`public: true`** \u2014 the shopfront (grid + product-detail variants) reads keyless (see below);\n the transactional collections (`carts`/`orders`/`coupons`) are **not** public.\n- `carts` / `cart_items` \u2014 owner-scoped carts per shopper (guest checkout via anonymous auth);\n each line snapshots `unit_price_cents` + `price_ref` at add-to-cart.\n- `orders` \u2014 unique `number` + unique `cart_ref`, with a state-machine hook guarding status transitions.\n- `coupons` / `coupon_redemptions` \u2014 codes plus one row per redemption; cap redemption writes\n with a write-body `guards[]` under one `lock` (the cms \xA710 pattern \u2014 shown in the config, not wired into `checkout.ts`).\n- `reviews` \u2014 owner-scoped, 1\u20135 rating enforced by a hook, moderated via draft/publish.\n- Features: `cms`, `auth`, `payments` (Stripe, BYO keys), `notifications`, `functions`; four functions \u2014\n `checkout` (the saga), `price-cart` (pricing engine), `on-order-paid` (receipt + fulfillment webhook),\n `abandoned-cart` (hourly cron nudge).\n\n**Apply it:**\n\n```bash\nvxil init --template storefront\nvxil quickstart\nvxil secrets set stripe_secret && vxil secrets set stripe_webhook\nvxil push\nvxil seed # demo products + the WELCOME10 coupon\nvxil gen\n```\n\n**What to learn from this:**\n1. **The checkout saga** (`functions/checkout.ts`) \u2014 reserve inventory \u2192 create the order \u2192 open the\n payment, compensating on any failure. All-or-nothing across features, no cross-feature ACID needed.\n Invoke `checkout`/`price-cart` from YOUR backend (server key): with `strictEndUserScope` on, an\n end-user-mode invocation is correctly denied on the shared collections they touch\n (`cart_items`/`variants`/`coupons`) \u2014 inventory is tenant-wide by design.\n2. **Oversell-safety** \u2014 `variants.stock` with `validation: { min: 0 }` makes `PATCH { $inc: { stock: -qty } }`\n a single-statement conditional decrement: exactly one winner under N concurrent buyers.\n3. **Exactly-once placement** \u2014 `orders.cart_ref` is declared `unique: true` in the config (carried by\n `vxil push`, `cms.md` \xA79.3), so N racing checkouts of one cart yield exactly one order \u2014 the rest see\n the `409 unique_violation` and `checkout.ts` answers `already_placed` (idempotent).\n\n```typescript\n// browse the live catalog with a server key (typed SDK)\nconst { items } = await vx.from('products').query({\n filter: { $status: 'published' },\n sort: '-price_cents',\n limit: 25,\n});\n```\n\n4. **The public shopfront \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). `products` and `variants` are\n `public: true`, so a static/JAMstack storefront renders the grid and product-detail pages with **no API\n key** \u2014 the edge forces `status = 'published'` and edge-caches the response. Only the write path (cart,\n checkout, admin) needs a key; the transactional collections are never public.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public grid \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'products', { sort: '-price_cents', limit: 24 });\n// a product's sellable variants (price, availability, provider price_ref)\nconst variants = await listCmsPublic('ten_your_tenant_id', 'variants', { filter: { product: productId } });\n```\n\n**Reader UI (optional):** drop [`@vxil/react/comments`](../../packages/react) onto a product page\nfor the `reviews` thread + a file uploader (enable the `comments` feature) \u2014 a pure client-side component\nover the shipped API. It installs via npm into a bundled app (no served `.mjs`).\n\n**Go deeper:** [`examples/ecommerce`](../../examples/ecommerce) is the fully-annotated deep version of this\nblueprint; vxil.com/docs/guide/04-data-with-cms (**public delivery**), vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (`$inc`, `lock`/`guards[]` on the item write routes),\nvxil.com/docs/guide/06-feature-catalog (payments),\nvxil.com/docs/guide/08-running-your-code-functions, and `templates/catalog/` (a content-only\npublic product grid).\n",
8218
8303
  "functions": {
8219
- "abandoned-cart.ts": "// abandoned-cart.ts \u2014 RETENTION CRON (a vxil function, \xA77.3).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep open carts that went stale (last_activity\n// older than 1h) using the slot-indexed range filter, and nudge the shopper. This is the\n// jobs-cron pattern \u2014 no new primitive, just a scheduled function.\n\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string> }\ninterface Cart { status: string; last_activity: string; end_user?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.org';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // carts still `open` whose last_activity is > 1h ago (t1 range filter, index-served)\n const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString();\n const filter = enc({ status: 'open', last_activity: { $lt: cutoff } });\n const res = await fetch(`${base}/v1/cms/items/carts?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: Cart }[] } };\n const carts = body.data?.items ?? [];\n\n // POST /v1/notifications/send is { user_id, template, data } \u2014 `transactional` is the\n // shipped generic template (requires data.subject + data.paragraph, notifications.md \xA77).\n let nudged = 0;\n for (const c of carts) {\n if (!notif || !c.data.end_user) continue;\n await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${notif}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_id: c.data.end_user,\n template: 'transactional',\n data: {\n subject: 'You left items in your cart',\n paragraph: `Your cart (${c.item_id}) is still waiting \u2014 come back and finish checkout any time.`,\n },\n }),\n });\n nudged++;\n }\n return Response.json({ scanned: carts.length, nudged });\n },\n};\n\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n",
8220
- "checkout.ts": "// checkout.ts \u2014 THE CHECKOUT SAGA (a vxil function, \xA77.3).\n//\n// The hard part of e-commerce: \"reserve N SKUs + capture payment + create the order,\n// all-or-nothing\" \u2014 which is the deliberately-REJECTED cross-feature-ACID case. The\n// doctrinal (and incumbent-identical) answer is a reserve\u2192settle\u2192reverse SAGA, and it\n// is exactly-once under any concurrency. Shopify+Stripe do the same thing (Stripe is a\n// physically separate system reconciled by webhook); nothing here is a platform gap.\n//\n// Invoke it SERVER-SIDE (your backend POSTs /v1/fn/checkout with a server key):\n// under cms.strictEndUserScope an end-user-mode invocation is correctly denied on\n// the shared collections this saga touches (cart_items/variants) \u2014 inventory is a\n// tenant-wide surface, so the reserve step is server work by design.\n//\n// Steps:\n// 1. read the cart (owner + currency) + its lines (cms:read)\n// 2. RESERVE each line: PATCH variant {$inc:{stock:-qty}} \u2014 validation.min:0 makes it a\n// single-statement oversell-safe decrement (409 inc_out_of_bounds if insufficient).\n// On any failure \u2192 COMPENSATE (re-$inc the ones already reserved) \u2192 409 out_of_stock.\n// 3. create the ORDER with a unique cart_ref \u2192 EXACTLY-ONCE (409 on a racing duplicate).\n// 4. open a payments checkout-session (mode:payment, Idempotency-Key = order number).\n// 5. return { order_id, checkout_url }. Capture completes async \u2192 functions/on-order-paid.ts.\n// (Not shipped here: if payment never completes, schedule a jobs `deliver_after`\n// release that re-$inc's the reserve.)\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n end_user?: { id: string };\n // the caller's HTTP body rides the invocation envelope under `payload` (functions.md \xA72)\n payload?: { cart_id?: string; success_url?: string; cancel_url?: string };\n}\ninterface Line { variant: string; qty: number; unit_price_cents: number; price_ref: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.org';\n const cms = env.scoped_jwts?.cms;\n const pay = env.scoped_jwts?.payments;\n if (!cms || !pay) return json({ error: 'missing cms/payments scope' }, 403);\n const { cart_id, success_url, cancel_url } = env.payload ?? {};\n if (!cart_id) return json({ error: 'cart_id required' }, 400);\n\n const H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n\n // 1. read the cart (its end_user owner + currency), then its lines\n const cartRes = await fetch(`${base}/v1/cms/items/carts/${cart_id}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!cartRes.ok) return json({ error: 'cart_not_found' }, 404);\n const cart = ((await cartRes.json()) as { data?: { data?: { end_user?: string; currency?: string } } }).data?.data ?? {};\n const shopper = env.end_user?.id ?? cart.end_user;\n if (!shopper) return json({ error: 'cart has no owner (end_user)' }, 400);\n const lines = await get<Line>(`${base}/v1/cms/items/cart_items?filter=${enc({ cart: cart_id })}&limit=100`, cms);\n if (lines.length === 0) return json({ error: 'empty cart' }, 400);\n\n // 2. RESERVE inventory line-by-line (oversell-safe $inc). Track for compensation.\n const reserved: Line[] = [];\n for (const ln of lines) {\n const r = await fetch(`${base}/v1/cms/items/variants/${ln.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: -ln.qty } }),\n });\n if (!r.ok) {\n // compensate everything reserved so far, then fail cleanly\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'out_of_stock', variant: ln.variant }, 409);\n }\n reserved.push(ln);\n }\n\n // 3. create the ORDER \u2014 unique cart_ref makes placement exactly-once under concurrency.\n const total = lines.reduce((s, l) => s + l.unit_price_cents * l.qty, 0);\n const number = `ORD-${cart_id.slice(0, 8)}`;\n const orderRes = await fetch(`${base}/v1/cms/items/orders`, {\n method: 'POST', headers: H(cms),\n body: JSON.stringify({\n data: {\n number, cart_ref: cart_id, status: 'pending', end_user: shopper,\n total_cents: total, placed_at: new Date().toISOString(), lines,\n },\n }),\n });\n if (orderRes.status === 409) {\n // a concurrent checkout already placed this cart \u2192 idempotent: report it placed\n return json({ status: 'already_placed', number }, 200);\n }\n if (!orderRes.ok) {\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'order_create_failed' }, 502);\n }\n const order = (await orderRes.json()) as { data?: { item_id?: string } };\n\n // 4. open the hosted payment (one-time). Idempotency-Key = order number \u21D2 safe to retry.\n // The documented checkout-sessions contract (payments.md \xA73): user_id + line_items\n // [{ price_ref, quantity, amount_cents?, currency? }] + mode + success/cancel URLs.\n // price_ref is the PROVIDER's price id (a Stripe Price) snapshot on the cart line \u2014\n // Stripe's adapter charges by price id; amount_cents/currency serve amount-based\n // providers (PayPal payment mode). NOTE: the shipped Stripe adapter charges\n // line_items[0] only \u2014 for multi-line carts on Stripe, collapse to one provider\n // line (or one order-total price) before opening the session.\n const currency = cart.currency ?? 'usd';\n const sess = await fetch(`${base}/v1/payments/checkout-sessions`, {\n method: 'POST',\n headers: { ...H(pay), 'idempotency-key': number },\n body: JSON.stringify({\n user_id: shopper,\n mode: 'payment',\n line_items: lines.map((l) => ({ price_ref: l.price_ref, quantity: l.qty, amount_cents: l.unit_price_cents, currency })),\n success_url: success_url ?? 'https://storefront.example/checkout/success',\n cancel_url: cancel_url ?? 'https://storefront.example/checkout/cancel',\n }),\n });\n if (!sess.ok) {\n // the order stays placed (pending) \u2014 surface the payment error so the caller can\n // retry the session (same Idempotency-Key) after fixing price_refs / provider keys.\n return json({ order_id: order.data?.item_id, number, error: 'payment_session_failed' }, 502);\n }\n const s = (await sess.json()) as { data?: { url?: string } };\n\n return json({ order_id: order.data?.item_id, number, checkout_url: s.data?.url }, 201);\n },\n};\n\n// \u2500\u2500 tiny helpers (the vxil REST envelope is { data: { items }, meta }; items carry item_id) \u2500\u2500\nasync function get<T>(url: string, jwt: string): Promise<T[]> {\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: T }[] } };\n return (body.data?.items ?? []).map((i) => ({ id: i.item_id, ...i.data } as T));\n}\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
8221
- "on-order-paid.ts": "// on-order-paid.ts \u2014 SETTLEMENT SIDE-EFFECTS (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.updated for `orders`. When the order flips to\n// `paid` \u2014 YOUR payment-success handler PATCHes it (e.g. a function subscribed to the\n// payments `payments.charge.succeeded` event via a webhooks-out subscription on the\n// `payments.` prefix, or your backend after the hosted checkout returns); the\n// order_transition hook validates the flip \u2014 fan out the side-effects:\n// email the receipt (notifications) and POST the fulfillment webhook to the tenant's\n// 3PL/warehouse over the egress allowlist. Delivery is at-least-once with retry/DLQ \u2014\n// identical semantics to Shopify Flow / a Stripe webhook fan-out.\n//\n// The cms-hook payload is { event, collection, item_id } \u2014 NOT the row \u2014 so the\n// function RE-FETCHES the order by id (through the edge, tenant-scoped). notifications:send\n// is a legitimate function scope (allowed by the deploy; docs/automation.md \xA74a).\n//\n// (Inventory was already reserved atomically at checkout, so there is no decrement here \u2014\n// the reservation simply becomes permanent. A payment FAILURE path compensates instead.)\n\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string>; payload?: { collection?: string; item_id?: string } }\ninterface OrderData { number?: string; status?: string; total_cents?: number; end_user?: string }\ninterface Item { data?: { data?: OrderData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.org';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n if (env.payload?.collection !== 'orders' || !cms || !itemId) return Response.json({ skipped: true });\n\n // Re-fetch the order (the payload carries only the id) and act only on pending\u2192paid.\n const res = await fetch(`${base}/v1/cms/items/orders/${itemId}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const order = ((await res.json()) as Item).data?.data ?? {};\n if (order.status !== 'paid') return Response.json({ skipped: true, status: order.status });\n\n // 1. receipt email (in-app inbox + email via the configured provider).\n if (notif && order.end_user) {\n await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${notif}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_id: order.end_user,\n template: 'transactional',\n data: { subject: `Receipt for order ${order.number}`, paragraph: `Thanks! Your order ${order.number} totalling ${order.total_cents} cents is confirmed.` },\n }),\n }).catch(() => { /* the jobs/webhooks retry+DLQ engine owns durability */ });\n }\n\n // 2. fulfillment webhook to the tenant's warehouse (egress-guarded to fulfillment.example.com).\n await fetch('https://fulfillment.example.com/orders', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ number: order.number, total_cents: order.total_cents }),\n }).catch(() => { /* best-effort here */ });\n\n return Response.json({ settled: order.number });\n },\n};\n",
8222
- "price-cart.ts": "// price-cart.ts \u2014 THE PRICING ENGINE (a vxil function, \xA77.3).\n//\n// This is the module people assume needs a \"promotions feature\". It does NOT \u2014 and it\n// deliberately is NOT a cms lifecycle hook: hooks are single-row and cross-row aggregation\n// is forbidden by design (hooks.ts), so a hook can't sum a cart, apply BOGO across items,\n// or evaluate cart-level thresholds. That is arbitrary domain logic \u2192 a FUNCTION with full\n// JS expressiveness (exactly how Shopify Functions / Scripts run tenant discount code) \u2192 [B].\n//\n// It reads the cart lines + coupon (cms:read) and returns the priced cart. Like checkout,\n// invoke it SERVER-SIDE: under cms.strictEndUserScope the shared collections it reads\n// (cart_items/coupons) are correctly denied to an end-user-mode invocation. checkout.ts\n// recomputes its total from the same server-held snapshots \u2014 never trust a client total;\n// to honor promotions at capture time, apply this function's output there the same way.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n // the caller's HTTP body rides the invocation envelope under `payload` (functions.md \xA72)\n payload?: { cart_id?: string; coupon_code?: string };\n}\ninterface Line { variant: string; qty: number; unit_price_cents: number }\ninterface Coupon { code: string; kind: string; value: number; max_uses: number }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.org';\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"
8304
+ "abandoned-cart.ts": "// abandoned-cart.ts \u2014 RETENTION CRON (a vxil function, \xA77.3).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep open carts that went stale (last_activity\n// older than 1h) using the slot-indexed range filter, and nudge the shopper. This is the\n// jobs-cron pattern \u2014 no new primitive, just a scheduled function.\n\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string> }\ninterface Cart { status: string; last_activity: string; end_user?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // carts still `open` whose last_activity is > 1h ago (t1 range filter, index-served)\n const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString();\n const filter = enc({ status: 'open', last_activity: { $lt: cutoff } });\n const res = await fetch(`${base}/v1/cms/items/carts?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: Cart }[] } };\n const carts = body.data?.items ?? [];\n\n // POST /v1/notifications/send is { user_id, template, data } \u2014 `transactional` is the\n // shipped generic template (requires data.subject + data.paragraph, notifications.md \xA77).\n let nudged = 0;\n for (const c of carts) {\n if (!notif || !c.data.end_user) continue;\n await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${notif}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_id: c.data.end_user,\n template: 'transactional',\n data: {\n subject: 'You left items in your cart',\n paragraph: `Your cart (${c.item_id}) is still waiting \u2014 come back and finish checkout any time.`,\n },\n }),\n });\n nudged++;\n }\n return Response.json({ scanned: carts.length, nudged });\n },\n};\n\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n",
8305
+ "checkout.ts": "// checkout.ts \u2014 THE CHECKOUT SAGA (a vxil function, \xA77.3).\n//\n// The hard part of e-commerce: \"reserve N SKUs + capture payment + create the order,\n// all-or-nothing\" \u2014 which is the deliberately-REJECTED cross-feature-ACID case. The\n// doctrinal (and incumbent-identical) answer is a reserve\u2192settle\u2192reverse SAGA, and it\n// is exactly-once under any concurrency. Shopify+Stripe do the same thing (Stripe is a\n// physically separate system reconciled by webhook); nothing here is a platform gap.\n//\n// Invoke it SERVER-SIDE (your backend POSTs /v1/fn/checkout with a server key):\n// under cms.strictEndUserScope an end-user-mode invocation is correctly denied on\n// the shared collections this saga touches (cart_items/variants) \u2014 inventory is a\n// tenant-wide surface, so the reserve step is server work by design.\n//\n// Steps:\n// 1. read the cart (owner + currency) + its lines (cms:read)\n// 2. RESERVE each line: PATCH variant {$inc:{stock:-qty}} \u2014 validation.min:0 makes it a\n// single-statement oversell-safe decrement (409 inc_out_of_bounds if insufficient).\n// On any failure \u2192 COMPENSATE (re-$inc the ones already reserved) \u2192 409 out_of_stock.\n// 3. create the ORDER with a unique cart_ref \u2192 EXACTLY-ONCE (409 on a racing duplicate).\n// 4. open a payments checkout-session (mode:payment, Idempotency-Key = order number).\n// 5. return { order_id, checkout_url }. Capture completes async \u2192 functions/on-order-paid.ts.\n// (Not shipped here: if payment never completes, schedule a jobs `deliver_after`\n// release that re-$inc's the reserve.)\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n end_user?: { id: string };\n // the caller's HTTP body rides the invocation envelope under `payload` (functions.md \xA72)\n payload?: { cart_id?: string; success_url?: string; cancel_url?: string };\n}\ninterface Line { variant: string; qty: number; unit_price_cents: number; price_ref: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const pay = env.scoped_jwts?.payments;\n if (!cms || !pay) return json({ error: 'missing cms/payments scope' }, 403);\n const { cart_id, success_url, cancel_url } = env.payload ?? {};\n if (!cart_id) return json({ error: 'cart_id required' }, 400);\n\n const H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n\n // 1. read the cart (its end_user owner + currency), then its lines\n const cartRes = await fetch(`${base}/v1/cms/items/carts/${cart_id}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!cartRes.ok) return json({ error: 'cart_not_found' }, 404);\n const cart = ((await cartRes.json()) as { data?: { data?: { end_user?: string; currency?: string } } }).data?.data ?? {};\n const shopper = env.end_user?.id ?? cart.end_user;\n if (!shopper) return json({ error: 'cart has no owner (end_user)' }, 400);\n const lines = await get<Line>(`${base}/v1/cms/items/cart_items?filter=${enc({ cart: cart_id })}&limit=100`, cms);\n if (lines.length === 0) return json({ error: 'empty cart' }, 400);\n\n // 2. RESERVE inventory line-by-line (oversell-safe $inc). Track for compensation.\n const reserved: Line[] = [];\n for (const ln of lines) {\n const r = await fetch(`${base}/v1/cms/items/variants/${ln.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: -ln.qty } }),\n });\n if (!r.ok) {\n // compensate everything reserved so far, then fail cleanly\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'out_of_stock', variant: ln.variant }, 409);\n }\n reserved.push(ln);\n }\n\n // 3. create the ORDER \u2014 unique cart_ref makes placement exactly-once under concurrency.\n const total = lines.reduce((s, l) => s + l.unit_price_cents * l.qty, 0);\n const number = `ORD-${cart_id.slice(0, 8)}`;\n const orderRes = await fetch(`${base}/v1/cms/items/orders`, {\n method: 'POST', headers: H(cms),\n body: JSON.stringify({\n data: {\n number, cart_ref: cart_id, status: 'pending', end_user: shopper,\n total_cents: total, placed_at: new Date().toISOString(), lines,\n },\n }),\n });\n if (orderRes.status === 409) {\n // a concurrent checkout already placed this cart \u2192 idempotent: report it placed\n return json({ status: 'already_placed', number }, 200);\n }\n if (!orderRes.ok) {\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'order_create_failed' }, 502);\n }\n const order = (await orderRes.json()) as { data?: { item_id?: string } };\n\n // 4. open the hosted payment (one-time). Idempotency-Key = order number \u21D2 safe to retry.\n // The documented checkout-sessions contract (payments.md \xA73): user_id + line_items\n // [{ price_ref, quantity, amount_cents?, currency? }] + mode + success/cancel URLs.\n // price_ref is the PROVIDER's price id (a Stripe Price) snapshot on the cart line \u2014\n // Stripe's adapter charges by price id; amount_cents/currency serve amount-based\n // providers (PayPal payment mode). NOTE: the shipped Stripe adapter charges\n // line_items[0] only \u2014 for multi-line carts on Stripe, collapse to one provider\n // line (or one order-total price) before opening the session.\n const currency = cart.currency ?? 'usd';\n const sess = await fetch(`${base}/v1/payments/checkout-sessions`, {\n method: 'POST',\n headers: { ...H(pay), 'idempotency-key': number },\n body: JSON.stringify({\n user_id: shopper,\n mode: 'payment',\n line_items: lines.map((l) => ({ price_ref: l.price_ref, quantity: l.qty, amount_cents: l.unit_price_cents, currency })),\n success_url: success_url ?? 'https://storefront.example/checkout/success',\n cancel_url: cancel_url ?? 'https://storefront.example/checkout/cancel',\n }),\n });\n if (!sess.ok) {\n // the order stays placed (pending) \u2014 surface the payment error so the caller can\n // retry the session (same Idempotency-Key) after fixing price_refs / provider keys.\n return json({ order_id: order.data?.item_id, number, error: 'payment_session_failed' }, 502);\n }\n const s = (await sess.json()) as { data?: { url?: string } };\n\n return json({ order_id: order.data?.item_id, number, checkout_url: s.data?.url }, 201);\n },\n};\n\n// \u2500\u2500 tiny helpers (the vxil REST envelope is { data: { items }, meta }; items carry item_id) \u2500\u2500\nasync function get<T>(url: string, jwt: string): Promise<T[]> {\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: T }[] } };\n return (body.data?.items ?? []).map((i) => ({ id: i.item_id, ...i.data } as T));\n}\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
8306
+ "on-order-paid.ts": "// on-order-paid.ts \u2014 SETTLEMENT SIDE-EFFECTS (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.updated for `orders`. When the order flips to\n// `paid` \u2014 YOUR payment-success handler PATCHes it (e.g. a function subscribed to the\n// payments `payments.charge.succeeded` event via a webhooks-out subscription on the\n// `payments.` prefix, or your backend after the hosted checkout returns); the\n// order_transition hook validates the flip \u2014 fan out the side-effects:\n// email the receipt (notifications) and POST the fulfillment webhook to the tenant's\n// 3PL/warehouse over the egress allowlist. Delivery is at-least-once with retry/DLQ \u2014\n// identical semantics to Shopify Flow / a Stripe webhook fan-out.\n//\n// The cms-hook payload is { event, collection, item_id } \u2014 NOT the row \u2014 so the\n// function RE-FETCHES the order by id (through the edge, tenant-scoped). notifications:send\n// is a legitimate function scope (allowed by the deploy; https://vxil.com/docs/guide/08-running-your-code-functions).\n//\n// (Inventory was already reserved atomically at checkout, so there is no decrement here \u2014\n// the reservation simply becomes permanent. A payment FAILURE path compensates instead.)\n\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string>; payload?: { collection?: string; item_id?: string } }\ninterface OrderData { number?: string; status?: string; total_cents?: number; end_user?: string }\ninterface Item { data?: { data?: OrderData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n if (env.payload?.collection !== 'orders' || !cms || !itemId) return Response.json({ skipped: true });\n\n // Re-fetch the order (the payload carries only the id) and act only on pending\u2192paid.\n const res = await fetch(`${base}/v1/cms/items/orders/${itemId}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const order = ((await res.json()) as Item).data?.data ?? {};\n if (order.status !== 'paid') return Response.json({ skipped: true, status: order.status });\n\n // 1. receipt email (in-app inbox + email via the configured provider).\n if (notif && order.end_user) {\n await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${notif}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_id: order.end_user,\n template: 'transactional',\n data: { subject: `Receipt for order ${order.number}`, paragraph: `Thanks! Your order ${order.number} totalling ${order.total_cents} cents is confirmed.` },\n }),\n }).catch(() => { /* the jobs/webhooks retry+DLQ engine owns durability */ });\n }\n\n // 2. fulfillment webhook to the tenant's warehouse (egress-guarded to fulfillment.example.com).\n await fetch('https://fulfillment.example.com/orders', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ number: order.number, total_cents: order.total_cents }),\n }).catch(() => { /* best-effort here */ });\n\n return Response.json({ settled: order.number });\n },\n};\n",
8307
+ "price-cart.ts": "// price-cart.ts \u2014 THE PRICING ENGINE (a vxil function, \xA77.3).\n//\n// This is the module people assume needs a \"promotions feature\". It does NOT \u2014 and it\n// deliberately is NOT a cms lifecycle hook: hooks are single-row and cross-row aggregation\n// is forbidden by design (hooks.ts), so a hook can't sum a cart, apply BOGO across items,\n// or evaluate cart-level thresholds. That is arbitrary domain logic \u2192 a FUNCTION with full\n// JS expressiveness (exactly how Shopify Functions / Scripts run tenant discount code) \u2192 [B].\n//\n// It reads the cart lines + coupon (cms:read) and returns the priced cart. Like checkout,\n// invoke it SERVER-SIDE: under cms.strictEndUserScope the shared collections it reads\n// (cart_items/coupons) are correctly denied to an end-user-mode invocation. checkout.ts\n// recomputes its total from the same server-held snapshots \u2014 never trust a client total;\n// to honor promotions at capture time, apply this function's output there the same way.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n // the caller's HTTP body rides the invocation envelope under `payload` (functions.md \xA72)\n payload?: { cart_id?: string; coupon_code?: string };\n}\ninterface Line { variant: string; qty: number; unit_price_cents: number }\ninterface Coupon { code: string; kind: string; value: number; max_uses: number }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const { cart_id, coupon_code } = env.payload ?? {};\n if (!cart_id) return Response.json({ error: 'cart_id required' }, { status: 400 });\n\n const lines = await get<Line>(`${base}/v1/cms/items/cart_items?filter=${enc({ cart: cart_id })}&limit=100`, cms);\n\n // subtotal (cross-row sum \u2014 the thing a hook can't do)\n const subtotal = lines.reduce((s, l) => s + l.unit_price_cents * l.qty, 0);\n\n // \u2500\u2500 arbitrary promotion rules, plain JS \u2500\u2500\n let discount = 0;\n const applied: string[] = [];\n\n // BOGO on any 2+ identical lines: cheapest unit free per pair\n for (const l of lines) {\n if (l.qty >= 2) { discount += Math.floor(l.qty / 2) * l.unit_price_cents; applied.push('bogo'); }\n }\n\n // tiered cart threshold: 5% over $100, 10% over $250\n if (subtotal >= 25000) { discount += Math.round(subtotal * 0.10); applied.push('tier-10'); }\n else if (subtotal >= 10000) { discount += Math.round(subtotal * 0.05); applied.push('tier-5'); }\n\n // coupon (percent or fixed) \u2014 stacks on top, capped so total never goes below 0\n if (coupon_code) {\n const [c] = await get<Coupon>(`${base}/v1/cms/items/coupons?filter=${enc({ code: coupon_code })}&limit=1`, cms);\n if (c) {\n discount += c.kind === 'percent' ? Math.round(subtotal * (c.value / 100)) : c.value;\n applied.push(`coupon:${c.code}`);\n }\n }\n\n const total = Math.max(0, subtotal - discount);\n return Response.json({ subtotal_cents: subtotal, discount_cents: subtotal - total, total_cents: total, applied });\n },\n};\n\nasync function get<T>(url: string, jwt: string): Promise<T[]> {\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: T }[] } };\n return (body.data?.items ?? []).map((i) => ({ id: i.item_id, ...i.data } as T));\n}\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n"
8223
8308
  }
8224
8309
  },
8225
8310
  {
@@ -8237,7 +8322,7 @@ export default defineConfig({
8237
8322
  "hasFunctions": false,
8238
8323
  "byoKeys": [],
8239
8324
  "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// \"Catalog\" \u2014 a CONTENT-ONLY product catalog (browse/list products by category),\n// declared end-to-end in ONE typed file. A BLUEPRINT composing shipped blocks \u2014\n// \u2022 cms \u2192 categories \u2192 products (resolved by relation)\n// This is the catalog CONTENT SHELL, not a storefront: checkout/inventory/orders\n// are the full `examples/ecommerce` blueprint (a reserve\u2192settle\u2192reverse saga in\n// tenant functions). Keep those in a function; a catalog is pure content.\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 draftPublish: true, // draft products while staging; publish to list them\n hooks: {\n // Price (if given) can't be negative \u2014 a pure function of the row.\n product_price: {\n collection: 'products',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'isNull(item.price_cents) || item.price_cents >= 0',\n message: 'price_cents must be >= 0',\n },\n },\n },\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 categories: {\n singular: 'category',\n // PUBLIC DELIVERY (cms.md \xA716): the category nav reads keyless too, so a\n // static storefront front-end can render the browse tree with no API key.\n public: true,\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n },\n },\n products: {\n singular: 'product',\n // PUBLIC DELIVERY (cms.md \xA716): a content catalog IS a reader-facing surface \u2014\n // published products are readable with NO API key over\n // GET /v1/cms/public/:tenantId/products, edge-cached, drafts never served.\n // A JAMstack storefront (or the served `listCmsPublic()` helper) fetches the\n // grid anonymously; only your admin writes need a key.\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: 'relation', relationTo: 'categories', indexSlot: 's3' },\n brand: { type: 'string', indexSlot: 's4' },\n price_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n rating: { type: 'float', indexSlot: 'n2' },\n description: { type: 'text' },\n images: { type: 'json' }, // [\"obj_\u2026\", \u2026] file refs or urls\n in_stock: { type: 'bool' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'products',\n items: [\n { title: 'Aeron Chair', slug: 'aeron-chair', brand: 'Herman Miller', price_cents: 149900, rating: 4.8, in_stock: true },\n { title: 'Standing Desk', slug: 'standing-desk', brand: 'Uplift', price_cents: 59900, rating: 4.6, in_stock: true },\n ],\n },\n ],\n },\n});\n",
8240
- "readme": "# Product Catalog template\n\nA **content-only** product catalog \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `categories` \u2014 name + unique slug. **`public: true`** \u2014 the browse nav reads keyless.\n- `products` \u2014 title, unique slug, `category` relation (slot-bound for filtering), brand, `price_cents`\n (`min: 0`), rating, JSON `images`, `in_stock`. A Lane-A hook rejects a negative price. **`public: true`** \u2014\n the product grid reads keyless (see below).\n\n**This is the catalog shell, not a storefront.** Checkout, inventory, orders, and coupons \u2014 the transactional spine\n\u2014 are the full **`examples/ecommerce`** blueprint (a reserve\u2192settle\u2192reverse saga in tenant `functions`, plus the\noversell-safe `$inc` stock pattern and `guards[]` coupon caps). A catalog is pure content; add the commerce logic\nfrom that example when you need it.\n\n**Use it:**\n\n```bash\nvxil init --template catalog\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The same invariant, two layers** \u2014 `price_cents` carries declarative `validation: { min: 0 }` AND a Lane-A\n validate hook: field validation covers one field, a hook expression can span the whole row\n (`docs/features/cms.md` \xA77).\n- **Numeric slots buy range + sort** \u2014 `price_cents` (`n1`) and `rating` (`n2`) make \"under $100, best-rated\n first\" a fully index-served query (\xA73).\n- **Content shell vs transactional spine** \u2014 cross-row commerce math (carts, stock, coupons) belongs in\n `functions`, never in a Lane-A hook.\n\n```ts\n// storefront browse with a server key: published products under $100, best-rated first\nconst { items } = await vx.from('products').query({\n filter: { price_cents: { $lte: 10000 }, $status: 'published' }, sort: '-rating', limit: 24,\n});\n```\n\n**The public grid \u2014 keyless** (`docs/features/cms.md` \xA716). Because `products` and `categories` are\n`public: true`, a static storefront renders with **no API key**: the edge forces `status = 'published'`\n(drafts never served) and edge-caches the response. Only admin writes need a key.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public grid \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'products', { sort: '-rating', limit: 24 });\n// one category's products \u2014 the \xA712.1 single-hop dotted-key join\nconst inCat = await listCmsPublic('ten_your_tenant_id', 'products', { filter: { 'category.slug': 'furniture' } });\n```\n\n**Go deeper:** `docs/features/cms.md` (\xA73 \xB7 \xA77 \xB7 \xA79 concurrency \xB7 **\xA716 public delivery**) \xB7\n`templates/storefront/` (the full commerce blueprint with checkout + a public shopfront) \xB7\n`examples/ecommerce/` (the full commerce saga) \xB7 `templates/blog/` (the same content shape for posts).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
8325
+ "readme": "# Product Catalog template\n\nA **content-only** product catalog \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `categories` \u2014 name + unique slug. **`public: true`** \u2014 the browse nav reads keyless.\n- `products` \u2014 title, unique slug, `category` relation (slot-bound for filtering), brand, `price_cents`\n (`min: 0`), rating, JSON `images`, `in_stock`. A Lane-A hook rejects a negative price. **`public: true`** \u2014\n the product grid reads keyless (see below).\n\n**This is the catalog shell, not a storefront.** Checkout, inventory, orders, and coupons \u2014 the transactional spine\n\u2014 are the full **`examples/ecommerce`** blueprint (a reserve\u2192settle\u2192reverse saga in tenant `functions`, plus the\noversell-safe `$inc` stock pattern and `guards[]` coupon caps). A catalog is pure content; add the commerce logic\nfrom that example when you need it.\n\n**Use it:**\n\n```bash\nvxil init --template catalog\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The same invariant, two layers** \u2014 `price_cents` carries declarative `validation: { min: 0 }` AND a Lane-A\n validate hook: field validation covers one field, a hook expression can span the whole row\n (vxil.com/docs/guide/07-validation-and-hooks).\n- **Numeric slots buy range + sort** \u2014 `price_cents` (`n1`) and `rating` (`n2`) make \"under $100, best-rated\n first\" a fully index-served query (\xA73).\n- **Content shell vs transactional spine** \u2014 cross-row commerce math (carts, stock, coupons) belongs in\n `functions`, never in a Lane-A hook.\n\n```ts\n// storefront browse with a server key: published products under $100, best-rated first\nconst { items } = await vx.from('products').query({\n filter: { price_cents: { $lte: 10000 }, $status: 'published' }, sort: '-rating', limit: 24,\n});\n```\n\n**The public grid \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `products` and `categories` are\n`public: true`, a static storefront renders with **no API key**: the edge forces `status = 'published'`\n(drafts never served) and edge-caches the response. Only admin writes need a key.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public grid \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'products', { sort: '-rating', limit: 24 });\n// one category's products \u2014 the \xA712.1 single-hop dotted-key join\nconst inCat = await listCmsPublic('ten_your_tenant_id', 'products', { filter: { 'category.slug': 'furniture' } });\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (queries \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7 vxil.com/docs/api (the item write routes) \xB7\n`templates/storefront/` (the full commerce blueprint with checkout + a public shopfront) \xB7\n`examples/ecommerce/` (the full commerce saga) \xB7 `templates/blog/` (the same content shape for posts).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
8241
8326
  "functions": {}
8242
8327
  },
8243
8328
  {
@@ -8265,7 +8350,7 @@ export default defineConfig({
8265
8350
  // \u2022 auth \u2192 accounts, so a booker is a verified end-user
8266
8351
  // \u2022 notifications \u2192 confirmation emails from your own code
8267
8352
  //
8268
- // THE POINT of this template is \`lock\` + \`guards[]\` (docs/features/cms.md \xA710):
8353
+ // THE POINT of this template is \`lock\` + \`guards[]\` (vxil.com/docs/api: the item write routes):
8269
8354
  // capacity is NOT config and NOT a hook \u2014 it is a declarative invariant that
8270
8355
  // rides the WRITE REQUEST BODY. The canonical oversell-proof booking write is:
8271
8356
  //
@@ -8332,7 +8417,7 @@ export default defineConfig({
8332
8417
  },
8333
8418
  bookings: {
8334
8419
  singular: 'booking',
8335
- // End-user owner-scope (docs/features/cms.md \xA715): a verified booker may
8420
+ // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a verified booker may
8336
8421
  // only read/edit their OWN booking. Server callers are unaffected.
8337
8422
  ownerField: 'member',
8338
8423
  fields: {
@@ -8364,7 +8449,7 @@ export default defineConfig({
8364
8449
  },
8365
8450
  });
8366
8451
  `,
8367
- "readme": '# Event Bookings template\n\nEvents/appointments with **race-proof capacity** \u2014 declared end-to-end in one typed `vxil.config.ts`.\nCapacity here is not a hope or a cron sweep: it is a declarative invariant enforced atomically at write\ntime, so overselling is structurally impossible.\n\n**Provisions:**\n- `events` \u2014 title, unique slug, `starts_at`/`ends_at` (slot-bound for range queries), `capacity`, status.\n- `bookings` \u2014 `event` relation, `member` (the **owner field** \u2014 a booker only sees their own),\n status (`booked | cancelled`, enforced by a Lane-A validate hook), `booked_at`.\n- `auth` (email/password) \u2014 a booker is a verified end-user.\n- `notifications` \u2014 send confirmations from your own code (`mock` provider until you wire a real one).\n\n**Use it:**\n\n```bash\nvxil init --template bookings\nvxil quickstart\nvxil push\nvxil seed # the demo event ("Launch Workshop", capacity 3) \u2014 push alone doesn\'t seed\nvxil gen\n```\n\n**What to learn from this:**\n1. **Per-key serialization (`lock`).** `lock: "evt:<event_id>"` takes a per-(tenant,collection,key)\n advisory lock as the first statement of the write transaction \u2014 every writer of THIS event queues;\n writers of other events don\'t contend.\n2. **Multi-invariant `guards[]`.** Each guard asserts *"after this write, at most `max` live items match\n `filter`"* \u2014 seat capacity AND one-booking-per-member, checked co-atomically under the ONE lock.\n Guards ride the **write request body**, never config (`docs/features/cms.md` \xA710).\n3. **Exactly one winner.** N concurrent bookings of the last seat: the lock serializes them, the first\n passes both counts and commits (**201**); every later one counts a full event and gets **409 `guard_failed`**\n with your `message` ("event is full"). Cancelling (PATCH `status` \u2192 `\'cancelled\'`) frees the seat,\n since the guard filters only count `status: \'booked\'` rows.\n\nThe canonical oversell-proof write (curl; `$EVENT_ID` = the seeded event, capacity 3 \u2014 note the\ntop-level `"status"` is the draft/published **lifecycle**, distinct from the booking\'s own `status` field):\n\n```bash\ncurl -s -X POST "$EDGE/v1/cms/items/bookings" \\\n -H "Authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d \'{\n "data": { "event": "\'$EVENT_ID\'", "member": "\'$ME\'", "status": "booked", "booked_at": "2026-08-01T17:00:00Z" },\n "status": "published",\n "lock": "evt:\'$EVENT_ID\'",\n "guards": [\n { "filter": { "event": "\'$EVENT_ID\'", "status": "booked" }, "max": 3, "message": "event is full" },\n { "filter": { "event": "\'$EVENT_ID\'", "member": "\'$ME\'", "status": "booked" }, "max": 1, "message": "already booked" }\n ]\n }\'\n```\n\nOr the typed SDK \u2014 `vx.from(...).create` takes `lock`/`guards` as write options:\n\n```ts\nconst { item_id } = await vx.from(\'bookings\').create(\n { event: eventId, member: me, status: \'booked\', booked_at: new Date().toISOString() },\n {\n status: \'published\',\n lock: `evt:${eventId}`,\n guards: [\n { filter: { event: eventId, status: \'booked\' }, max: capacity, message: \'event is full\' },\n { filter: { event: eventId, member: me, status: \'booked\' }, max: 1, message: \'already booked\' },\n ],\n },\n);\n```\n\n(`max` is the event\'s `capacity` \u2014 read the event row first; the guard re-counts atomically at write\ntime, so a stale seat COUNT can never oversell. A concurrently edited `capacity` is still read\npre-lock \u2014 pass the freshest value you have.)\n\n**Go deeper:** `docs/features/cms.md` \xA710 (lock/guard/guards), \xA77 (Lane-A hooks), \xA715 (owner-scoping);\n`docs/features/auth.md`; `examples/ecommerce/`, which teaches the same idiom for coupon redemption caps.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
8452
+ "readme": '# Event Bookings template\n\nEvents/appointments with **race-proof capacity** \u2014 declared end-to-end in one typed `vxil.config.ts`.\nCapacity here is not a hope or a cron sweep: it is a declarative invariant enforced atomically at write\ntime, so overselling is structurally impossible.\n\n**Provisions:**\n- `events` \u2014 title, unique slug, `starts_at`/`ends_at` (slot-bound for range queries), `capacity`, status.\n- `bookings` \u2014 `event` relation, `member` (the **owner field** \u2014 a booker only sees their own),\n status (`booked | cancelled`, enforced by a Lane-A validate hook), `booked_at`.\n- `auth` (email/password) \u2014 a booker is a verified end-user.\n- `notifications` \u2014 send confirmations from your own code (`mock` provider until you wire a real one).\n\n**Use it:**\n\n```bash\nvxil init --template bookings\nvxil quickstart\nvxil push\nvxil seed # the demo event ("Launch Workshop", capacity 3) \u2014 push alone doesn\'t seed\nvxil gen\n```\n\n**What to learn from this:**\n1. **Per-key serialization (`lock`).** `lock: "evt:<event_id>"` takes a per-(tenant,collection,key)\n advisory lock as the first statement of the write transaction \u2014 every writer of THIS event queues;\n writers of other events don\'t contend.\n2. **Multi-invariant `guards[]`.** Each guard asserts *"after this write, at most `max` live items match\n `filter`"* \u2014 seat capacity AND one-booking-per-member, checked co-atomically under the ONE lock.\n Guards ride the **write request body**, never config (vxil.com/docs/api: the item write routes).\n3. **Exactly one winner.** N concurrent bookings of the last seat: the lock serializes them, the first\n passes both counts and commits (**201**); every later one counts a full event and gets **409 `guard_failed`**\n with your `message` ("event is full"). Cancelling (PATCH `status` \u2192 `\'cancelled\'`) frees the seat,\n since the guard filters only count `status: \'booked\'` rows.\n\nThe canonical oversell-proof write (curl; `$EVENT_ID` = the seeded event, capacity 3 \u2014 note the\ntop-level `"status"` is the draft/published **lifecycle**, distinct from the booking\'s own `status` field):\n\n```bash\ncurl -s -X POST "$EDGE/v1/cms/items/bookings" \\\n -H "Authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d \'{\n "data": { "event": "\'$EVENT_ID\'", "member": "\'$ME\'", "status": "booked", "booked_at": "2026-08-01T17:00:00Z" },\n "status": "published",\n "lock": "evt:\'$EVENT_ID\'",\n "guards": [\n { "filter": { "event": "\'$EVENT_ID\'", "status": "booked" }, "max": 3, "message": "event is full" },\n { "filter": { "event": "\'$EVENT_ID\'", "member": "\'$ME\'", "status": "booked" }, "max": 1, "message": "already booked" }\n ]\n }\'\n```\n\nOr the typed SDK \u2014 `vx.from(...).create` takes `lock`/`guards` as write options:\n\n```ts\nconst { item_id } = await vx.from(\'bookings\').create(\n { event: eventId, member: me, status: \'booked\', booked_at: new Date().toISOString() },\n {\n status: \'published\',\n lock: `evt:${eventId}`,\n guards: [\n { filter: { event: eventId, status: \'booked\' }, max: capacity, message: \'event is full\' },\n { filter: { event: eventId, member: me, status: \'booked\' }, max: 1, message: \'already booked\' },\n ],\n },\n);\n```\n\n(`max` is the event\'s `capacity` \u2014 read the event row first; the guard re-counts atomically at write\ntime, so a stale seat COUNT can never oversell. A concurrently edited `capacity` is still read\npre-lock \u2014 pass the freshest value you have.)\n\n**Go deeper:** vxil.com/docs/api (lock/guard/guards on the item write routes), vxil.com/docs/guide/07-validation-and-hooks (Lane-A hooks), vxil.com/docs/guide/04-data-with-cms (owner-scoping);\nvxil.com/docs/guide/06-feature-catalog (auth); `examples/ecommerce/`, which teaches the same idiom for coupon redemption caps.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
8368
8453
  "functions": {}
8369
8454
  },
8370
8455
  {
@@ -8429,7 +8514,7 @@ export default defineConfig({
8429
8514
  collections: {
8430
8515
  tickets: {
8431
8516
  singular: 'ticket',
8432
- // End-user owner-scope (docs/features/cms.md \xA715): a signed-in requester
8517
+ // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a signed-in requester
8433
8518
  // reads/edits ONLY their own tickets \u2014 one declarative flag, a no-op for
8434
8519
  // server callers (your agent backend sees the whole queue).
8435
8520
  ownerField: 'requester',
@@ -8497,10 +8582,10 @@ export default defineConfig({
8497
8582
  },
8498
8583
  });
8499
8584
  `,
8500
- "readme": "# Helpdesk / Support Ticketing template\n\nA support-ticketing backend \u2014 requester-owned tickets with a hook-enforced status state machine,\nthreaded conversation messages, an acknowledgement send on every new ticket, and an hourly\nSLA-escalation cron \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**What it provisions:**\n- `tickets` \u2014 subject, `status` (state machine below), priority, `requester` (the **owner field**),\n `opened_at`, `sla_due`, body. Every queue-driving field is slot-indexed for filter/sort.\n- `ticket_messages` \u2014 the conversation thread: `ticket` relation, author, body, `sent_at`.\n- Features: `cms` + `auth` (email/password end-users) + `notifications` (mock provider) + `functions`.\n- Functions: `on-ticket-created` (cmsHook \u2192 acknowledgement send) and `sla-sweep` (hourly cron).\n\n**Apply it:**\n\n```bash\nvxil init --template helpdesk\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **A status state machine in a Lane-A hook** \u2014 the `beforeUpdate` validate allows only\n `open\u2192pending|resolved`, `pending\u2192open|resolved`, `resolved\u2192closed`; any other transition is a\n clean 422, atomically, in the write itself (`docs/features/cms.md` \xA77).\n- **SLA automation as a cron function** \u2014 `sla-sweep` queries breached tickets with the \xA73 filter DSL\n (`status $in` + `sla_due $lt`, slot-indexed) and PATCHes `priority: 'urgent'`; the `$ne: 'urgent'`\n term makes re-runs idempotent.\n- **Requester-scoped end-user access** \u2014 `tickets.ownerField = 'requester'`: a signed-in requester\n sees and edits only their **own** tickets (`docs/features/cms.md` \xA715); server keys see the queue.\n\n```ts\nconst { item_id } = await vx.from('tickets').create({\n subject: 'Cannot sign in on mobile', status: 'open', priority: 'normal',\n requester: 'user_demo', opened_at: new Date().toISOString(),\n sla_due: new Date(Date.now() + 8 * 3600e3).toISOString(), body: 'Steps to reproduce\u2026',\n});\n\n// the agent queue, most-overdue first (slot-indexed \u2192 typed filter/sort, index-served)\nconst { items } = await vx.from('tickets').query({\n filter: { status: { $in: ['open', 'pending'] } }, sort: 'sla_due', limit: 25,\n});\n```\n\n**Go deeper:** `docs/features/cms.md` (\xA73 query DSL \xB7 \xA77 hooks \xB7 \xA715 owner-scoping) \xB7\n`docs/features/functions.md` \xB7 `docs/features/notifications.md` \xB7 `examples/ecommerce/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
8585
+ "readme": "# Helpdesk / Support Ticketing template\n\nA support-ticketing backend \u2014 requester-owned tickets with a hook-enforced status state machine,\nthreaded conversation messages, an acknowledgement send on every new ticket, and an hourly\nSLA-escalation cron \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**What it provisions:**\n- `tickets` \u2014 subject, `status` (state machine below), priority, `requester` (the **owner field**),\n `opened_at`, `sla_due`, body. Every queue-driving field is slot-indexed for filter/sort.\n- `ticket_messages` \u2014 the conversation thread: `ticket` relation, author, body, `sent_at`.\n- Features: `cms` + `auth` (email/password end-users) + `notifications` (mock provider) + `functions`.\n- Functions: `on-ticket-created` (cmsHook \u2192 acknowledgement send) and `sla-sweep` (hourly cron).\n\n**Apply it:**\n\n```bash\nvxil init --template helpdesk\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **A status state machine in a Lane-A hook** \u2014 the `beforeUpdate` validate allows only\n `open\u2192pending|resolved`, `pending\u2192open|resolved`, `resolved\u2192closed`; any other transition is a\n clean 422, atomically, in the write itself (vxil.com/docs/guide/07-validation-and-hooks).\n- **SLA automation as a cron function** \u2014 `sla-sweep` queries breached tickets with the \xA73 filter DSL\n (`status $in` + `sla_due $lt`, slot-indexed) and PATCHes `priority: 'urgent'`; the `$ne: 'urgent'`\n term makes re-runs idempotent.\n- **Requester-scoped end-user access** \u2014 `tickets.ownerField = 'requester'`: a signed-in requester\n sees and edits only their **own** tickets (vxil.com/docs/guide/04-data-with-cms); server keys see the queue.\n\n```ts\nconst { item_id } = await vx.from('tickets').create({\n subject: 'Cannot sign in on mobile', status: 'open', priority: 'normal',\n requester: 'user_demo', opened_at: new Date().toISOString(),\n sla_due: new Date(Date.now() + 8 * 3600e3).toISOString(), body: 'Steps to reproduce\u2026',\n});\n\n// the agent queue, most-overdue first (slot-indexed \u2192 typed filter/sort, index-served)\nconst { items } = await vx.from('tickets').query({\n filter: { status: { $in: ['open', 'pending'] } }, sort: 'sla_due', limit: 25,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 owner-scoping) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/08-running-your-code-functions \xB7 vxil.com/docs/guide/06-feature-catalog (notifications) \xB7 `examples/ecommerce/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
8501
8586
  "functions": {
8502
- "on-ticket-created.ts": "// on-ticket-created.ts \u2014 the ACKNOWLEDGEMENT hook (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `tickets`. On a CREATE, send the\n// requester an acknowledgement through notifications. The cms-hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// ticket by id (through the edge, tenant-scoped) rather than trusting inline fields.\n// Delivery is at-least-once: the envelope idempotency_key rides the send as its\n// Idempotency-Key header, so a redelivered hook never double-sends.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n idempotency_key?: string;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\ninterface TicketData { subject?: string; status?: string; requester?: string; sla_due?: string }\ninterface Item { data?: { data?: TicketData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.org';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n if (env.payload?.collection !== 'tickets' || !cms || !notif || !itemId) {\n return Response.json({ skipped: true });\n }\n // acknowledge only the CREATE (the cms.item.* subscription also delivers updates)\n if (!String(env.payload?.event ?? '').endsWith('.created')) {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n\n // Re-fetch the ticket (the payload carries only the id).\n const res = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const ticket = ((await res.json()) as Item).data?.data ?? {};\n if (!ticket.requester) return Response.json({ skipped: true, reason: 'no requester' });\n\n // Acknowledge to the requester (email/inbox via the configured provider).\n const send = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n ...(env.idempotency_key ? { 'idempotency-key': env.idempotency_key } : {}),\n },\n body: JSON.stringify({\n user_id: ticket.requester,\n template: 'transactional',\n data: {\n subject: `We got your ticket: ${ticket.subject ?? itemId}`,\n paragraph:\n `Your ticket is ${ticket.status ?? 'open'} and in our queue` +\n `${ticket.sla_due ? ` (response due by ${ticket.sla_due})` : ''}. ` +\n 'Reply in the app to add details.',\n },\n }),\n });\n return Response.json({ acknowledged: itemId, delivery: send.status });\n },\n};\n",
8503
- "sla-sweep.ts": "// sla-sweep.ts \u2014 SLA ESCALATION CRON (a vxil function, \xA77.3).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep tickets whose sla_due has passed and\n// that are still open/pending \u2014 the cms filter DSL of docs/features/cms.md \xA73:\n// `status $in` on the s2 slot, `sla_due $lt` on the t2 slot, `priority $ne` on\n// s3 \u2014 all index-served. Each breach is escalated with a PATCH to priority\n// 'urgent'; the $ne term makes re-runs idempotent (an escalated ticket falls out\n// of the filter). The Lane-A state-machine hook still runs on every PATCH; a\n// priority-only write keeps item.status == before.status, so it always passes.\n\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string> }\ninterface Ticket { id: string; data: { subject?: string; priority?: string } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.org';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // breached = still open/pending, past its sla_due, not yet urgent\n const filter = enc({\n status: { $in: ['open', 'pending'] },\n sla_due: { $lt: new Date().toISOString() },\n priority: { $ne: 'urgent' },\n });\n const res = await fetch(`${base}/v1/cms/items/tickets?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ error: `query ${res.status}` }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: Ticket[] } };\n const breached = body.data?.items ?? [];\n\n let escalated = 0;\n for (const t of breached) {\n const r = await fetch(`${base}/v1/cms/items/tickets/${t.id}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { priority: 'urgent' } }),\n });\n if (r.ok) escalated++;\n }\n return Response.json({ scanned: breached.length, escalated });\n },\n};\n\n// \u2500\u2500 tiny helper (the vxil REST list envelope is { data: { items } }) \u2500\u2500\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n"
8587
+ "on-ticket-created.ts": "// on-ticket-created.ts \u2014 the ACKNOWLEDGEMENT hook (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `tickets`. On a CREATE, send the\n// requester an acknowledgement through notifications. The cms-hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// ticket by id (through the edge, tenant-scoped) rather than trusting inline fields.\n// Delivery is at-least-once: the envelope idempotency_key rides the send as its\n// Idempotency-Key header, so a redelivered hook never double-sends.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n idempotency_key?: string;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\ninterface TicketData { subject?: string; status?: string; requester?: string; sla_due?: string }\ninterface Item { data?: { data?: TicketData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n if (env.payload?.collection !== 'tickets' || !cms || !notif || !itemId) {\n return Response.json({ skipped: true });\n }\n // acknowledge only the CREATE (the cms.item.* subscription also delivers updates)\n if (!String(env.payload?.event ?? '').endsWith('.created')) {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n\n // Re-fetch the ticket (the payload carries only the id).\n const res = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const ticket = ((await res.json()) as Item).data?.data ?? {};\n if (!ticket.requester) return Response.json({ skipped: true, reason: 'no requester' });\n\n // Acknowledge to the requester (email/inbox via the configured provider).\n const send = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n ...(env.idempotency_key ? { 'idempotency-key': env.idempotency_key } : {}),\n },\n body: JSON.stringify({\n user_id: ticket.requester,\n template: 'transactional',\n data: {\n subject: `We got your ticket: ${ticket.subject ?? itemId}`,\n paragraph:\n `Your ticket is ${ticket.status ?? 'open'} and in our queue` +\n `${ticket.sla_due ? ` (response due by ${ticket.sla_due})` : ''}. ` +\n 'Reply in the app to add details.',\n },\n }),\n });\n return Response.json({ acknowledged: itemId, delivery: send.status });\n },\n};\n",
8588
+ "sla-sweep.ts": "// sla-sweep.ts \u2014 SLA ESCALATION CRON (a vxil function, \xA77.3).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep tickets whose sla_due has passed and\n// that are still open/pending \u2014 the cms filter DSL (vxil.com/docs/guide/04-data-with-cms):\n// `status $in` on the s2 slot, `sla_due $lt` on the t2 slot, `priority $ne` on\n// s3 \u2014 all index-served. Each breach is escalated with a PATCH to priority\n// 'urgent'; the $ne term makes re-runs idempotent (an escalated ticket falls out\n// of the filter). The Lane-A state-machine hook still runs on every PATCH; a\n// priority-only write keeps item.status == before.status, so it always passes.\n\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string> }\ninterface Ticket { id: string; data: { subject?: string; priority?: string } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // breached = still open/pending, past its sla_due, not yet urgent\n const filter = enc({\n status: { $in: ['open', 'pending'] },\n sla_due: { $lt: new Date().toISOString() },\n priority: { $ne: 'urgent' },\n });\n const res = await fetch(`${base}/v1/cms/items/tickets?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ error: `query ${res.status}` }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: Ticket[] } };\n const breached = body.data?.items ?? [];\n\n let escalated = 0;\n for (const t of breached) {\n const r = await fetch(`${base}/v1/cms/items/tickets/${t.id}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { priority: 'urgent' } }),\n });\n if (r.ok) escalated++;\n }\n return Response.json({ scanned: breached.length, escalated });\n },\n};\n\n// \u2500\u2500 tiny helper (the vxil REST list envelope is { data: { items } }) \u2500\u2500\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n"
8504
8589
  }
8505
8590
  },
8506
8591
  {
@@ -8521,9 +8606,34 @@ export default defineConfig({
8521
8606
  "hasFunctions": false,
8522
8607
  "byoKeys": [],
8523
8608
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Sales CRM\" \u2014 companies \u2192 contacts \u2192 a deal pipeline, declared end-to-end in\n// ONE typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 companies \u2192 contacts \u2192 deals (resolved by relation), with a\n// Lane-A STATE MACHINE guarding the pipeline stage\n// \u2022 activity-feed \u2192 the per-deal timeline (calls, emails, notes) + the rep bell\n// \u2022 notifications \u2192 the email channel (mock provider until you bring a key)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // THE PIPELINE STATE MACHINE (Lane-A validate \u2014 runs inside the write\n // transaction): a deal may only move lead \u2192 qualified \u2192 proposal \u2192\n // won | lost. Any other transition is a clean 422, rolled back\n // atomically. Cross-row work (e.g. \"email the rep on won\") belongs in\n // a function or webhook, never in a hook \u2014 hooks are pure row logic.\n deal_stage_machine: {\n collection: 'deals',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.stage == before.stage' +\n \" || (before.stage == 'lead' && item.stage == 'qualified')\" +\n \" || (before.stage == 'qualified' && item.stage == 'proposal')\" +\n \" || (before.stage == 'proposal' && (item.stage == 'won' || item.stage == 'lost'))\",\n message: 'illegal pipeline transition (lead \u2192 qualified \u2192 proposal \u2192 won|lost)',\n },\n },\n },\n\n // The per-deal timeline. The DEFAULT feed groups already fit a CRM:\n // `timeline` (flat, chronological) keyed per deal \u2014 ingest with\n // POST /v1/feeds/timeline/{deal_item_id}/activities { actor, verb, object }\n // \u2014 and the aggregated `notification` bell per rep.\n 'activity-feed': {},\n\n // Email channel. `mock` is the zero-config staging provider; for real\n // sends switch to provider: 'resend' and set `resendApiKeyRef`.\n notifications: { provider: 'mock', fromEmail: 'noreply@crm.app' },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n companies: {\n singular: 'company',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n // Want `domain` unique? Declare `unique: true` right here \u2014 value\n // uniqueness (cms.md \xA79.3) is carried by `vxil push` on collection/field\n // CREATE. Flipping it on an ALREADY-pushed field is an in-place alter\n // via the dashboard field designer or a same-name+type re-add through\n // POST /v1/cms/collections/companies/fields (push diffs name+type only).\n domain: { type: 'string', indexSlot: 's2' },\n industry: { type: 'string', indexSlot: 's3' },\n employees: { type: 'int', indexSlot: 'n1' },\n },\n },\n contacts: {\n singular: 'contact',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n email: { type: 'string', indexSlot: 's2' }, // unique \u21D2 see the `domain` note above\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n last_touch: { type: 'datetime', indexSlot: 't1' },\n phone: { type: 'string' }, // stored, not indexed\n },\n },\n deals: {\n singular: 'deal',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n // The pipeline stage: enum-validated on every write; the Lane-A hook\n // above additionally locks the TRANSITIONS between stages.\n stage: {\n type: 'string',\n required: true,\n indexSlot: 's2',\n validation: { enum: ['lead', 'qualified', 'proposal', 'won', 'lost'] },\n },\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n owner: { type: 'string', indexSlot: 's4' }, // the rep working the deal\n amount_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n close_date: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n },\n },\n\n // `vxil seed` POSTs each item verbatim \u2014 item_ids are assigned at runtime, so\n // the `company` relation on contacts/deals is left unset here; link records\n // after seeding (PATCH the created items with the company's item_id).\n seed: {\n cms: [\n {\n collection: 'companies',\n items: [{ name: 'Acme Corp', domain: 'acme.com', industry: 'Manufacturing', employees: 250 }],\n },\n {\n collection: 'contacts',\n items: [{ name: 'Jane Porter', email: 'jane@acme.com', phone: '+1 555 0100' }],\n },\n {\n collection: 'deals',\n items: [\n { title: 'Acme starter plan', stage: 'lead', owner: 'jordan', amount_cents: 480_000 },\n { title: 'Acme enterprise rollout', stage: 'proposal', owner: 'sam', amount_cents: 12_000_000 },\n ],\n },\n ],\n },\n});\n",
8524
- "readme": '# Sales CRM template\n\nA sales CRM backend \u2014 companies, contacts, and a deal pipeline \u2014 declared end-to-end in one typed\n`vxil.config.ts`. Stage transitions are guarded by a Lane-A state machine that runs inside the write\ntransaction, every deal gets an activity timeline via `activity-feed`, and `notifications` provides\nthe email channel (mock provider until you bring your own key).\n\n**What it provisions:**\n- `companies` \u2014 name, domain, industry, employee count (slot-bound for range/sort). To make\n `domain`/`email` unique, declare `unique: true` on the field \u2014 carried by `vxil push` on create\n (`cms.md` \xA79.3); flipping it on an already-pushed field is a dashboard/REST in-place alter.\n- `contacts` \u2014 name, email, `company` relation, `last_touch` datetime.\n- `deals` \u2014 title, enum-validated `stage`, `company` relation, `owner` (the rep), `amount_cents`,\n `close_date`. A Lane-A `validate` hook locks the pipeline: `lead \u2192 qualified \u2192 proposal \u2192 won|lost`.\n- Features: `cms` + `activity-feed` (per-deal timelines + the rep notification bell, default feed\n groups) + `notifications` (mock provider; swap to `resend` + `resendApiKeyRef` for real sends).\n\n**Apply it:**\n\n```bash\nvxil init --template crm\nvxil quickstart # or `vxil link` to an existing tenant\nvxil push\nvxil gen # then optionally: `vxil seed` (1 company, 1 contact, 2 deals)\n```\n\n**What to learn from this:**\n1. **The pipeline state machine** \u2014 a `validate` hook on `beforeUpdate` compares `item.stage` to\n `before.stage` and rejects any illegal transition with a clean 422, atomically in the same write.\n Field-level `validation.enum` handles membership; the hook handles the *transitions*.\n2. **Pipeline value by stage** \u2014 one bounded group-by aggregate (`stage` is slot-bound, `amount_cents`\n is an `n*` slot), no SQL:\n ```bash\n curl -X POST https://api.vxil.com/v1/cms/items/deals/aggregate \\\n -H "Authorization: Bearer $VXIL_KEY" -H \'content-type: application/json\' \\\n -d \'{"aggregates":[{"fn":"sum","field":"amount_cents","as":"pipeline_cents"},{"fn":"count"}],"groupBy":["stage"]}\'\n # \u2192 { "groups": [{ "key": {"stage":"proposal"}, "count": 1, "pipeline_cents": 12000000 }, \u2026], "scanned": \u2026 }\n ```\n3. **The deal timeline** \u2014 each call/email/note is one ingest to the default `timeline` feed group:\n `POST /v1/feeds/timeline/{deal_item_id}/activities` with `{ actor, verb, object }` (idempotent on\n `foreign_id` + `time`); read it back with keyset cursors, or typed via `vx.from(\'deals\').query(\u2026)`\n for the pipeline board itself.\n\n**Go deeper:** `docs/features/cms.md` (\xA77 hooks, \xA712.2 aggregates), `docs/features/activity-feed.md`,\n`docs/features/notifications.md`, and `examples/ecommerce/` for a bigger state machine in the wild.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
8609
+ "readme": '# Sales CRM template\n\nA sales CRM backend \u2014 companies, contacts, and a deal pipeline \u2014 declared end-to-end in one typed\n`vxil.config.ts`. Stage transitions are guarded by a Lane-A state machine that runs inside the write\ntransaction, every deal gets an activity timeline via `activity-feed`, and `notifications` provides\nthe email channel (mock provider until you bring your own key).\n\n**What it provisions:**\n- `companies` \u2014 name, domain, industry, employee count (slot-bound for range/sort). To make\n `domain`/`email` unique, declare `unique: true` on the field \u2014 carried by `vxil push` on create\n (`cms.md` \xA79.3); flipping it on an already-pushed field is a dashboard/REST in-place alter.\n- `contacts` \u2014 name, email, `company` relation, `last_touch` datetime.\n- `deals` \u2014 title, enum-validated `stage`, `company` relation, `owner` (the rep), `amount_cents`,\n `close_date`. A Lane-A `validate` hook locks the pipeline: `lead \u2192 qualified \u2192 proposal \u2192 won|lost`.\n- Features: `cms` + `activity-feed` (per-deal timelines + the rep notification bell, default feed\n groups) + `notifications` (mock provider; swap to `resend` + `resendApiKeyRef` for real sends).\n\n**Apply it:**\n\n```bash\nvxil init --template crm\nvxil quickstart # or `vxil link` to an existing tenant\nvxil push\nvxil gen # then optionally: `vxil seed` (1 company, 1 contact, 2 deals)\n```\n\n**What to learn from this:**\n1. **The pipeline state machine** \u2014 a `validate` hook on `beforeUpdate` compares `item.stage` to\n `before.stage` and rejects any illegal transition with a clean 422, atomically in the same write.\n Field-level `validation.enum` handles membership; the hook handles the *transitions*.\n2. **Pipeline value by stage** \u2014 one bounded group-by aggregate (`stage` is slot-bound, `amount_cents`\n is an `n*` slot), no SQL:\n ```bash\n curl -X POST https://api.vxil.com/v1/cms/items/deals/aggregate \\\n -H "Authorization: Bearer $VXIL_KEY" -H \'content-type: application/json\' \\\n -d \'{"aggregates":[{"fn":"sum","field":"amount_cents","as":"pipeline_cents"},{"fn":"count"}],"groupBy":["stage"]}\'\n # \u2192 { "groups": [{ "key": {"stage":"proposal"}, "count": 1, "pipeline_cents": 12000000 }, \u2026], "scanned": \u2026 }\n ```\n3. **The deal timeline** \u2014 each call/email/note is one ingest to the default `timeline` feed group:\n `POST /v1/feeds/timeline/{deal_item_id}/activities` with `{ actor, verb, object }` (idempotent on\n `foreign_id` + `time`); read it back with keyset cursors, or typed via `vx.from(\'deals\').query(\u2026)`\n for the pipeline board itself.\n\n**Go deeper:** vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (aggregates), vxil.com/docs/guide/06-feature-catalog (activity-feed,\nnotifications), and `examples/ecommerce/` for a bigger state machine in the wild.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
8525
8610
  "functions": {}
8526
8611
  },
8612
+ {
8613
+ "id": "team-workspace",
8614
+ "title": "Team Workspace (orgs \xB7 SSO \xB7 field-level security)",
8615
+ "vertical": "saas",
8616
+ "summary": "The B2B backend: customer organizations with memberships and a tenant-defined role, roles riding the session, a generic OIDC/SSO issuer as a one-block config swap, account-security controls and a 3-device session cap, plus a document set that is owner-scoped, delete-protected, carries one Archive button, and hides its budget field from anyone without the finance role.",
8617
+ "collections": [
8618
+ "projects",
8619
+ "documents"
8620
+ ],
8621
+ "features": [
8622
+ "orgs",
8623
+ "auth",
8624
+ "cms",
8625
+ "functions"
8626
+ ],
8627
+ "hasFunctions": true,
8628
+ "byoKeys": [
8629
+ "oidc_client_secret"
8630
+ ],
8631
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Team Workspace\" \u2014 the ENTERPRISE blueprint: many companies inside one\n// backend, each with its own members and roles, signing in through the\n// company's own identity provider, reading a document set where ONE field is\n// visible only to finance. Declared end-to-end in ONE typed file.\n//\n// \u2022 orgs \u2192 organizations + memberships + a tenant-defined `finance` role\n// \u2022 auth \u2192 email/password or magic link today, a generic OIDC issuer as a\n// config swap; account-security controls; a 3-device session cap\n// \u2022 cms \u2192 projects \u2192 documents, owner-scoped, `restrict`-protected, with\n// ONE per-record action button and ONE role-gated field\n// \u2022 functions \u2192 the single step the Archive button runs\n//\n// The one thing that is NOT in this file: inviting a teammate to the vxil\n// PROJECT itself (the dashboard's pending-email invite). That is an operator\n// flow, not app config \u2014 see the README.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n // \u2500\u2500 Workspaces for YOUR customers' teams \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // An organization is a customer company; a membership carries a role. The\n // built-in lattice is owner > admin > member > viewer; `finance` below is a\n // CUSTOM role you define once over the API (see the README) and then assign\n // like any built-in one.\n orgs: {\n enabled: true,\n maxMembersPerOrg: 200,\n invitationTtlHours: 72, // an org invitation token is single-use + TTL-bound\n },\n\n auth: {\n // The demo path: email+password (and magic link) so the walkthrough runs\n // with no identity provider at all.\n methods: { emailPassword: true, magicLink: true },\n\n // \u2500\u2500 SSO: the generic OIDC issuer, as a CONFIG SWAP \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Uncomment this block, store the client secret once, and every member of\n // the workspace signs in through the company's IdP instead. The endpoints\n // and signing keys are discovered from the issuer \u2014 nothing else changes\n // in this file, and no code changes at all. `clientId` is not a secret\n // (it rides every authorize URL); the secret stays a REFERENCE.\n //\n // providers: {\n // oidc: {\n // issuer: 'https://login.example-idp.com', // https, no query/fragment\n // clientId: 'vxil-team-workspace',\n // clientSecretRef: 'secret:oidc_client_secret', // the `secrets` block below\n // scopes: ['email', 'profile'], // `openid` is always added\n // claims: { email: 'email', name: 'name', roles: 'groups' },\n // allowedDomains: ['example.com'], // fail-closed domain fence\n // autoLink: true, // link to a matching verified email\n // },\n // },\n\n // Roles ride the SESSION. With this on, the member's active-org role is\n // embedded in the session at sign-in and refresh, so a read can be gated\n // on it without a round-trip. It is a SNAPSHOT (refreshed with the\n // session) \u2014 use the orgs permission check for revocation-grade calls.\n orgClaims: { enabled: true },\n\n // A member may hold at most three live sessions; a fourth sign-in takes\n // over the oldest (it is revoked, and the sign-in reports which).\n session: { ttlMinutes: 60, refreshTtlDays: 30, maxConcurrent: 3 },\n\n // \u2500\u2500 Account-security controls (all opt-in, all off by default) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n security: {\n // repeated bad passwords on one identifier \u2192 locked, with a retry hint\n lockout: { maxFailures: 5, windowMinutes: 15, lockMinutes: 15 },\n // refuse a sign-up / reset whose password appears in a breach corpus\n breachedPasswords: true,\n // EVERY caller-supplied return URL must match one of these exactly \u2014\n // the anti-open-redirect fence for magic links, resets and SSO returns.\n allowedRedirectOrigins: ['https://app.example.com'],\n // captchaSecretRef: 'turnstile_secret', // add to require a captcha token\n },\n },\n\n cms: {\n hooks: {\n // The DOCUMENT lifecycle, enforced atomically inside the same write.\n // `archived` is terminal; the Archive button below is just the last\n // legal transition, so the button and the API agree by construction.\n document_stage: {\n collection: 'documents',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.state == before.state'\n + \" || (before.state == 'draft' && (item.state == 'in_review' || item.state == 'archived'))\"\n + \" || (before.state == 'in_review' && (item.state == 'draft' || item.state == 'approved'))\"\n + \" || (before.state == 'approved' && item.state == 'archived')\",\n message: 'illegal document state transition',\n },\n },\n },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n projects: {\n singular: 'project',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n // one project code per workspace \u2014 a duplicate is a clean 409\n code: { type: 'string', unique: true, indexSlot: 's2' },\n stage: { type: 'string', indexSlot: 's3' }, // discovery | active | closed\n created_at: { type: 'datetime', indexSlot: 't1' },\n summary: { type: 'text' },\n },\n },\n\n documents: {\n singular: 'document',\n // Owner-scoping: a signed-in member reads/edits only their OWN\n // documents. A no-op for server callers \u2014 your own backend still sees\n // the whole set.\n ownerField: 'author',\n\n // ONE human-initiated step per record. The dashboard renders a button\n // on every row; pressing it invokes the named function ONCE with\n // { collection, item_id, action, actor, item }. No conditions, no\n // chaining, no scheduling \u2014 the moment it needs branches it is a\n // function of your own, not a button.\n actions: [{ key: 'archive', label: 'Archive', fn: 'archive-document' }],\n\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n author: { type: 'string', indexSlot: 's2' }, // the owner (end-user id)\n // `restrict`: while a live document points at a project, deleting\n // that project is REFUSED (409) instead of silently orphaning or\n // cascading. The reverse read (`\u2026/backlinks`) tells you who holds it.\n project: { type: 'relation', relationTo: 'projects', onDelete: 'restrict', indexSlot: 's3' },\n state: { type: 'string', indexSlot: 's4' }, // draft | in_review | approved | archived\n // \u2500\u2500 FIELD-LEVEL READ SECURITY \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Only a signed-in member whose session carries the `finance` role\n // ever receives this field. Everyone else gets the document WITHOUT\n // it \u2014 absent, not null \u2014 and cannot filter or sort on it either, so\n // it can never be read one bit at a time. Your own server key still\n // sees it: this gates END USERS, not you.\n budget_usd: { type: 'int', indexSlot: 'n1', readRoles: ['finance'] },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text' },\n },\n },\n },\n },\n\n // \u2500\u2500 The one step that isn't config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n functions: {\n // The Archive button. Invoked through the per-record action route with the\n // pressing member's verified principal carried whole, so the write it makes\n // is owner-scoped exactly as if the member had made it themselves.\n 'archive-document': {\n entry: './functions/archive-document.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // nothing external; it only talks back to your own backend\n },\n },\n\n // References only \u2014 values are stored once and never appear in this file.\n secrets: {\n oidc_client_secret: { feature: 'auth', description: 'OIDC client secret for the workspace identity provider' },\n },\n\n seed: {\n cms: [\n {\n collection: 'projects',\n items: [\n {\n name: 'Northwind Rollout',\n code: 'NW-2026',\n stage: 'active',\n created_at: '2026-01-06T09:00:00Z',\n summary: 'Migration of the Northwind account onto the new platform.',\n },\n ],\n },\n ],\n },\n});\n",
8632
+ "readme": '# Team Workspace (saas)\n\nThe **B2B** blueprint: many customer companies inside one backend, each with its own members\nand roles, signing in through the company\'s own identity provider \u2014 and a document set where\none field is visible only to finance.\n\nFive things most "add multi-tenancy to my SaaS" projects end up building by hand, declared here\ninstead: **organizations**, **roles that ride the session**, **SSO as a config swap**,\n**field-level read security**, and **one button per record**.\n\n```bash\nvxil init --template team-workspace\nvxil quickstart # or `vxil link <slug>` for an existing backend\nvxil push # collections + hooks + the archive function\n```\n\n## The five things, and where each one lives\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| Customer companies + memberships | `features.orgs` | `POST /v1/orgs`, members, invitations, a permission check \u2014 no membership table of your own |\n| A `finance` role | **not config** \u2014 `POST /v1/orgs/roles` | roles are rows, so you add one without a deploy |\n| Roles on the session | `features.auth.orgClaims.enabled` | the member\'s active-org role rides the session token; a read can be gated on it with no round-trip |\n| SSO | `features.auth.providers.oidc` (commented) | one block swaps email+password for the customer\'s identity provider |\n| Lockout / breach / redirect fence | `features.auth.security` | the account-security controls, all opt-in, all off until you ask |\n| A 3-device cap | `features.auth.session.maxConcurrent` | a fourth sign-in takes over the oldest session and tells you which |\n| Hiding `budget_usd` | `readRoles: [\'finance\']` on the field | the field is **absent** for everyone else \u2014 and unfilterable, so it cannot be read one bit at a time |\n| Refusing an orphaning delete | `onDelete: \'restrict\'` on the relation | deleting a project that still holds documents is a clean 409, not a cascade you did not ask for |\n| The Archive button | `actions: [{ key, label, fn }]` | one human-initiated step, one function, no workflow engine |\n\n## SSO \u2014 the config swap\n\nThe blueprint ships with email + password so the walkthrough runs with no identity provider.\nTo move a workspace onto its company\'s IdP, uncomment the `providers.oidc` block in\n`vxil.config.ts`, fill in three values, store one secret, and push:\n\n```ts\nproviders: {\n oidc: {\n issuer: \'https://login.example-idp.com\', // https, no query or fragment\n clientId: \'vxil-team-workspace\', // not a secret \u2014 it rides every authorize URL\n clientSecretRef: \'secret:oidc_client_secret\', // a REFERENCE; the value never enters this file\n scopes: [\'email\', \'profile\'], // `openid` is always added\n claims: { email: \'email\', name: \'name\', roles: \'groups\' },\n allowedDomains: [\'example.com\'], // fail-closed: an unlisted domain is refused\n autoLink: true, // link to an existing verified email\n },\n},\n```\n\n```bash\nprintf \'%s\' "$OIDC_SECRET" | vxil secrets set auth/oidc_client_secret\nvxil push\n```\n\nThen send people to `GET /v1/auth/oauth/oidc/start?redirect_uri=https://app.example.com/callback`.\nThe authorize endpoint, token endpoint and signing keys are **discovered from the issuer** \u2014 there\nis nothing else to configure and no code change at all. The presence of the block is the opt-in;\nthere is no separate toggle.\n\nTwo claims feed the session\'s role list: the member\'s **active-org role** (from `orgClaims`) and\nwhatever claim you name in `claims.roles` (from the IdP). Either one alone is enough to satisfy\n`readRoles: [\'finance\']`, which is why the same config works before and after SSO.\n\nAny broker that speaks OIDC \u2014 Okta, Entra, Auth0, WorkOS \u2014 puts a SAML customer behind this same\nblock. There is deliberately no separate SAML surface to learn.\n\n## Inviting people: two different invitations\n\nThey are easy to confuse, so name them apart:\n\n- **Your customers\' teammates** \u2192 `POST /v1/orgs/{org_id}/invitations` with `{ email, role }`,\n where `role` is `admin`, `member` or `viewer`. The single-use token comes back **once**, in that\n response \u2014 it is deliberately never emailed, so your app builds its own accept link and controls\n the wording. Accept with `POST /v1/orgs/invitations/accept`; list pending ones with\n `GET /v1/orgs/{org_id}/invitations`; revoke with `DELETE /v1/orgs/invitations/{invite_id}`.\n To land someone on a custom role such as `finance`, invite them as `member` and then\n `POST /v1/orgs/{org_id}/members` with the role. There is no resend \u2014 issue a new invitation and\n revoke the old one.\n- **Your own colleagues, on the vxil project itself** \u2192 the dashboard\'s **Members \u2192 Invite by\n email**. Type an address and it becomes a *pending* row with Resend and Revoke beside it; when\n they accept, they get a dashboard seat on this backend. That one is pure operator flow \u2014 no code,\n nothing in this config.\n\n## The 10-minute walkthrough\n\nEvery response below is the real shape. `$KEY` is a server key with `cms:read cms:write orgs:read\norgs:write auth:signin auth:write features:read`; `$API` is `https://api.vxil.com`.\n\n**1. Define the `finance` role.** Roles are rows, so this needs no deploy.\n\n```bash\nvxil api POST /v1/orgs/roles --data \'{"role_key":"finance","name":"Finance","permissions":["budgets.approve","reports.export"],"rank":2}\'\n# 201 { "data": { "role_key": "finance", "name": "Finance", "permissions": [...], "rank": 2, ... } }\n```\n\nA custom role is a named **permission set**, and the permission strings are yours \u2014 vxil never\ninterprets them, it only answers whether this member holds one. The four built-in roles\n(`owner > admin > member > viewer`) keep working alongside it.\n\n**2. Create a workspace and two members.**\n\n```bash\nvxil api POST /v1/orgs --data \'{"slug":"northwind","name":"Northwind","owner_user_id":"u_owner"}\'\n# 201 { "data": { "org_id": "org_\u2026", "slug": "northwind", "name": "Northwind", "created_at": "\u2026" } }\n```\n\nSign two people up, then seat them \u2014 one plain `member`, one on `finance`:\n\n```bash\nvxil api POST /v1/auth/sign-up --data \'{"email":"alice@example.com","password":"<a strong one>"}\'\nvxil api POST /v1/auth/sign-up --data \'{"email":"dana@example.com","password":"<a strong one>"}\'\nvxil api POST /v1/orgs/org_\u2026/members --data \'{"user_id":"<alice>","role":"member"}\'\nvxil api POST /v1/orgs/org_\u2026/members --data \'{"user_id":"<dana>","role":"finance"}\'\n# 201 { "data": { "org_id": "org_\u2026", "user_id": "\u2026", "role": "finance" } }\n```\n\nCheck what the session will carry:\n\n```bash\nvxil api GET "/v1/orgs/session-claims?user_id=<dana>"\n# 200 { "data": { "user_id": "\u2026", "org_id": "org_\u2026", "role": "finance", "perms": [...] } }\n```\n\n**3. Sign in \u2014 and watch the device cap.** Sign the same person in four times:\n\n```bash\ncurl -s -X POST "$API/v1/auth/sign-in" -H "authorization: Bearer $KEY" \\\n -H \'content-type: application/json\' \\\n -d \'{"email":"dana@example.com","password":"<a strong one>"}\'\n# 200 { "data": { "user_id": "\u2026",\n# "session": { "token": "\u2026", "refresh_token": "\u2026", "expires_at": "\u2026" },\n# "took_over": [ "sess_\u2026" ] } }\n```\n\nThe fourth sign-in reports the session it revoked in `took_over`. That array only appears because\n`session.maxConcurrent` is set \u2014 leave it out and responses are byte-identical to a backend that\nnever heard of the cap.\n\nRepeated wrong passwords stop being cheap after five: `429 account_locked` with a `Retry-After`\nheader, for fifteen minutes. Because the counter is keyed on a hash of the identifier, an unknown\naddress locks out exactly like a real one \u2014 no probing for which emails exist.\n\n**4. Write a document with a budget.** As the server key (no end-user session):\n\n```bash\nvxil api POST /v1/cms/items/projects --data \'{"data":{"name":"Northwind Rollout","code":"NW-2026","stage":"active"}}\'\nvxil api POST /v1/cms/items/documents --data \'{"data":{"title":"Statement of work","author":"<dana>","project":"<project item_id>","state":"draft","budget_usd":240000}}\'\n# 201 { "data": { "item_id": "itm_\u2026", "collection": "documents", "status": "draft",\n# "data": { "title": "\u2026", "budget_usd": 240000, \u2026 }, "version": 1, \u2026 } }\n```\n\nThe server key sees `budget_usd`. That is the point: the gate is for **end users**, not for you.\n\n**5. The gate, live.** Read the same document as a signed-in member, by sending the session\'s\n`token` in the `X-Vxil-End-User` header:\n\n```bash\n# dana \u2014 role `finance`\ncurl -s "$API/v1/cms/items/documents/<id>" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <dana\'s session token>"\n# 200 \u2026 "data": { "title": "Statement of work", "budget_usd": 240000, "state": "draft", \u2026 }\n\n# alice \u2014 role `member`\ncurl -s "$API/v1/cms/items/documents/<her own document\'s id>" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <alice\'s session token>"\n# 200 \u2026 "data": { "title": "\u2026", "state": "draft", \u2026 } \u2190 budget_usd is ABSENT\n```\n\nAbsent, not `null` \u2014 a `null` would itself be an answer. And it cannot be reached sideways either:\n\n```bash\ncurl -s "$API/v1/cms/items/documents?filter=%7B%22budget_usd%22%3A%7B%22%24gt%22%3A0%7D%7D" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <alice\'s session token>"\n# 422 { "error": { "code": "invalid_query", "message": "unknown field \'budget_usd\' \u2026" } }\n```\n\nTo a member without the role the field does not exist \u2014 not in the document, not in a filter, not\nin a sort, not through an expanded relation. Promote alice to `finance`, have her sign in again,\nand the field is simply there: the role travels on the session, so a new session is all it takes.\n\n**6. A delete that refuses.** The project still has a document pointing at it:\n\n```bash\nvxil api DELETE /v1/cms/items/projects/<project item_id>\n# 409 { "error": { "code": "referenced",\n# "message": "this item is still referenced by 1 live item(s) through an on_delete: \'restrict\' relation \u2026; nothing was deleted.",\n# "referencing": [ { "collection": "documents", "field": "project", "item_id": "itm_\u2026", "status": "draft" } ],\n# "has_more": false } }\n```\n\nAsk who is holding it, the same way the 409 did:\n\n```bash\nvxil api GET /v1/cms/items/projects/<project item_id>/backlinks\n# 200 { "data": { "collection": "projects", "item_id": "itm_\u2026",\n# "backlinks": [ { "collection": "documents", "field": "project", "item_id": "itm_\u2026", "status": "draft" } ],\n# "count": 1, "has_more": false, "limit": 25 } }\n```\n\n**7. The button.** One action, one function, one step:\n\n```bash\ncurl -s -X POST "$API/v1/cms/items/documents/<id>/actions/archive" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <dana\'s session token>"\n# 200 { "data": { "collection": "documents", "item_id": "itm_\u2026", "action": "archive",\n# "fn": "archive-document",\n# "result": { "archived": "itm_\u2026", "from": "draft", "state": "archived" } } }\n```\n\nThe function receives `{ collection, item_id, action, actor, item }` and runs with **the pressing\nmember\'s** verified identity, so its write is owner-scoped exactly as if they had made it. Press it\nagain and it answers `already: true` \u2014 a button a human can double-click needs to be idempotent.\n\nTry an illegal jump instead (`archived \u2192 draft`) and the collection\'s lifecycle hook rejects it\ninside the same write, so the button and the API can never disagree:\n\n```bash\nvxil api PATCH /v1/cms/items/documents/<id> --data \'{"data":{"state":"draft"}}\'\n# 422 \u2026 "illegal document state transition"\n```\n\n**8. Real authorization, when advisory is not enough.** The session role is a *snapshot*, refreshed\nwith the session. For anything that must reflect a revocation immediately, ask:\n\n```bash\nvxil api GET "/v1/orgs/org_\u2026/check?user_id=<dana>&permission=budgets.approve"\n# 200 { "data": { "org_id": "org_\u2026", "user_id": "\u2026", "permission": "budgets.approve",\n# "role": "finance", "allowed": true, "source": "role" } }\n```\n\n## What to learn from this\n\n- **Roles are data; the gate is config.** `finance` is a row you can create at 4pm on a Friday.\n `readRoles: [\'finance\']` is one field attribute. Neither is a code path you maintain.\n- **Field-level security has to be fail-safe in every direction, or it is theatre.** A gated field\n is removed from the document, from filters, from sorts, from expanded relations, and it is never\n served on a public read lane. The only caller that still sees it is your own backend.\n- **Owner-scoping and role-gating answer different questions.** `ownerField` decides *which rows*\n a member can see. `readRoles` decides *which fields* inside a row they get. You usually want both.\n- **`restrict` beats a cascade you did not think about.** Refusing the delete and naming the\n holders turns a data-loss bug into a 409 your UI can explain.\n- **An action is one step, on purpose.** The moment a button needs conditions or a second step, it\n is a function of yours, not a config entry \u2014 and that boundary is what keeps this from becoming a\n workflow engine.\n\n**Pairs with:** `templates/crm/` (the same relational spine without the org layer) and\n`templates/helpdesk/` (owner-scoped records with a state machine).\n',
8633
+ "functions": {
8634
+ "archive-document.ts": "// archive-document.ts \u2014 the ARCHIVE BUTTON (a vxil function).\n//\n// Trigger: the per-record action `archive` declared on the `documents`\n// collection. Pressing the button POSTs\n// /v1/cms/items/documents/<id>/actions/archive\n// and the platform invokes THIS function once with the action envelope as its\n// payload:\n// { collection, item_id, action, actor, item: { item_id, status, version, data } }\n//\n// The pressing member's verified principal is carried into the scoped token, so\n// the PATCH below is owner-scoped exactly as if the member had written it \u2014 a\n// member can archive their own document and nobody else's, with no check here.\n//\n// It writes ONE transition (\u2192 'archived'). The collection's lifecycle hook is\n// still the authority: an illegal transition is rejected in the same write, and\n// this function reports that rejection instead of pretending it succeeded.\n\ninterface ActionPayload {\n collection?: string;\n item_id?: string;\n action?: string;\n actor?: { principal?: string; end_user_id?: string };\n item?: { status?: string; version?: number; data?: Record<string, unknown> };\n}\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: ActionPayload;\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const itemId = env.payload?.item_id;\n if (!cms || !itemId || env.payload?.collection !== 'documents') {\n return Response.json({ skipped: true, reason: 'not a documents action' });\n }\n\n // Already archived \u2192 nothing to do. The action is human-initiated and can be\n // pressed twice; make the second press a no-op rather than an error.\n const was = String(env.payload?.item?.data?.state ?? '');\n if (was === 'archived') {\n return Response.json({ archived: itemId, already: true, state: 'archived' });\n }\n\n const res = await fetch(`${base}/v1/cms/items/documents/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: { state: 'archived', updated_at: new Date().toISOString() },\n }),\n });\n\n if (!res.ok) {\n // The lifecycle hook refuses an illegal transition in-transaction (422).\n // Surface the real reason \u2014 the action route relays this class straight\n // back to the caller as `action_failed`.\n const detail = await res.text();\n return Response.json(\n { error: 'archive_refused', from: was, status: res.status, detail: detail.slice(0, 300) },\n { status: res.status === 422 ? 422 : 502 },\n );\n }\n\n return Response.json({ archived: itemId, from: was || 'draft', state: 'archived' });\n },\n};\n"
8635
+ }
8636
+ },
8527
8637
  {
8528
8638
  "id": "leaderboard",
8529
8639
  "title": "Game Leaderboard",
@@ -8539,8 +8649,8 @@ export default defineConfig({
8539
8649
  ],
8540
8650
  "hasFunctions": false,
8541
8651
  "byoKeys": [],
8542
- "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// \"Leaderboard\" \u2014 game scores + live rankings, declared end-to-end in ONE typed\n// file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 players + scores. THE POINT: the leaderboards themselves are\n// never stored \u2014 `POST /v1/cms/items/scores/rank` and\n// `\u2026/aggregate` are bounded, config-free read-models over the\n// raw score rows (docs/features/cms.md \xA712.2\u201312.3), and\n// `?count=true` gives an exact \"entries ahead of me\" (\xA79.4).\n// \u2022 rate-limits \u2192 the abuse guard for PUBLIC score submission. Policies are\n// data on the feature's own REST surface (POST\n// /v1/rate-limits/policies + \u2026/check), not config leaves \u2014\n// see the README for the exact bodies.\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: {}, // defaults suffice \u2014 rankings are ad-hoc bounded reads, not config\n 'rate-limits': {}, // enabled with defaults; the submission policy is REST data\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 players: {\n singular: 'player',\n fields: {\n handle: { type: 'string', required: true, indexSlot: 's1', unique: true },\n display_name: { type: 'string', indexSlot: 's2' },\n country: { type: 'string', indexSlot: 's3' },\n },\n },\n // One row per RUN. Per-player \"best\" is derived at read time by\n // rank/aggregate (`max(score)` grouped by `player`) \u2014 nothing to keep\n // consistent on write.\n scores: {\n singular: 'score',\n fields: {\n // The ranked entity: rank/aggregate group by this field's s1 slot,\n // i.e. by the STORED value (for real submissions, the player item_id).\n player: { type: 'relation', relationTo: 'players', indexSlot: 's1' },\n game_mode: { type: 'string', required: true, indexSlot: 's2', validation: { enum: ['classic', 'blitz'] } },\n // n1 slot \u21D2 max/avg aggregates + `$gt` range (\"players ahead of me\").\n score: { type: 'int', required: true, indexSlot: 'n1', validation: { min: 0 } },\n // t1 slot \u21D2 `window: { field: 'achieved_at', sinceDays: 7 }` = weekly boards.\n achieved_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n seed: {\n // `vxil seed` POSTs these verbatim, in order \u2014 it cannot capture the\n // item_ids the player rows are assigned. The demo scores therefore carry\n // the players' stable HANDLES as the relation value (type-valid: a\n // relation stores an opaque string, and grouping ranks by the stored\n // value either way). Real submissions should store the item_id that\n // `create` returns \u2014 that is what makes `$expand` and dotted join\n // filters (e.g. `player.country`) resolve.\n cms: [\n {\n collection: 'players',\n items: [\n { handle: 'nova', display_name: 'Nova', country: 'JO' },\n { handle: 'rex', display_name: 'Rex', country: 'DE' },\n { handle: 'zed', display_name: 'Zed', country: 'JP' },\n ],\n },\n {\n collection: 'scores',\n items: [\n { player: 'nova', game_mode: 'classic', score: 12500, achieved_at: '2026-07-01T10:00:00Z' },\n { player: 'zed', game_mode: 'classic', score: 11200, achieved_at: '2026-07-02T09:15:00Z' },\n { player: 'rex', game_mode: 'classic', score: 9800, achieved_at: '2026-07-03T11:30:00Z' },\n { player: 'nova', game_mode: 'blitz', score: 4200, achieved_at: '2026-07-03T18:00:00Z' },\n { player: 'rex', game_mode: 'blitz', score: 5100, achieved_at: '2026-07-04T20:45:00Z' },\n ],\n },\n ],\n },\n});\n",
8543
- "readme": '# Game Leaderboard template\n\nGame scores + live rankings in one typed `vxil.config.ts`. The leaderboards themselves are **never stored** \u2014\ntop-N, per-mode stats, and "players ahead of me" are all bounded reads over the raw score rows, so there are no\ncounters, crons, or denormalized tables to keep consistent.\n\n**Provisions:**\n- `players` \u2014 unique `handle`, `display_name`, `country`.\n- `scores` \u2014 `player` relation (the ranked entity), `game_mode` (enum), `score` (int, `min: 0`), `achieved_at`.\n One row per run; per-player "best" is derived at read time.\n- `rate-limits` \u2014 enabled as the abuse guard for public score submission (the policy is REST data, see below).\n\n**Apply it:**\n\n```bash\nvxil init --template leaderboard\nvxil quickstart # or: vxil link\nvxil push\nvxil seed # 3 players + 5 demo scores across 2 game modes\nvxil gen\n```\n\n**What to learn from this:**\n1. **Config-free leaderboards.** `POST /v1/cms/items/scores/rank` and `\u2026/aggregate` are bounded read-models\n (\u2264500 groups over a \u226450k-row scan; an over-wide scan fails `422 window_too_large` instead of silently\n truncating \u2014 `docs/features/cms.md` \xA712.2\u201312.3). `window: { field: \'achieved_at\', sinceDays: 7 }` = weekly boards.\n2. **`?count=true` is uncapped.** List pages clamp at the page-size limit, but a count is one\n `SELECT count(*)` under the same compiled filter (\xA79.4) \u2014 an exact "entries ahead of me" even at\n position 40,000. It counts score *rows*; for strictly "players ahead" keep one row per (player, mode)\n (patch on a new best), or read the rank endpoint (within its \xA712 bounds).\n3. **rate-limits as the abuse guard.** Public score submission is the classic spam target: create a named\n policy once, then check per player before accepting a run (`docs/features/rate-limits.md` \xA71).\n\n**Top-10 by best score per player in a mode** (`VXIL_BASE` = your edge, e.g. `https://api.vxil.com`):\n\n```bash\ncurl -sX POST "$VXIL_BASE/v1/cms/items/scores/rank" \\\n -H "Authorization: Bearer $VXIL_KEY" -H "content-type: application/json" \\\n -d \'{"groupBy":"player","metric":{"fn":"max","field":"score"},\n "rank":"rank","direction":"desc","filter":{"game_mode":"classic"},"limit":10}\'\n# \u2192 { "data": { "entries": [ { "key": { "player": "nova" }, "metric": 12500, "rank": 1 }, \u2026 ], "scanned": 3 } }\n```\n\n**Per-mode stats + a player\'s position** (typed SDK, after `vxil gen`):\n\n```ts\nconst { groups } = await vx.from(\'scores\').aggregate({\n aggregates: [{ fn: \'count\', as: \'runs\' }, { fn: \'max\', field: \'score\', as: \'top\' }],\n groupBy: [\'game_mode\'],\n});\nconst ahead = await vx.from(\'scores\').count({ game_mode: \'classic\', score: { $gt: 11200 } });\n// position = ahead + 1\n```\n\n**The submission guard** (once, from your server):\n\n```bash\ncurl -sX POST "$VXIL_BASE/v1/rate-limits/policies" \\\n -H "Authorization: Bearer $VXIL_KEY" -H "content-type: application/json" \\\n -d \'{"name":"scores.submit","key_template":"{player_id}","limit":10,"window_seconds":60,"behavior":"block"}\'\n# per run: POST /v1/rate-limits/check {"policy_id":"rl_\u2026","key_values":{"player_id":"<player item_id>"},"cost":1} \u2192 429 when exhausted\n```\n\n> The demo seed stores player *handles* as the `scores.player` relation value (a seed can\'t know generated\n> item_ids); real submissions should store the item_id `create` returns so `$expand`/dotted joins resolve.\n\n**Go deeper:** `docs/features/cms.md` (\xA73 query DSL, \xA79.4 count, \xA712 aggregate/rank) \xB7\n`docs/features/rate-limits.md` (policies, checks, per-identifier overrides) \xB7\n`examples/ecommerce/` (a bigger cms composition, with functions).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
8652
+ "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// \"Leaderboard\" \u2014 game scores + live rankings, declared end-to-end in ONE typed\n// file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 players + scores. THE POINT: the leaderboards themselves are\n// never stored \u2014 `POST /v1/cms/items/scores/rank` and\n// `\u2026/aggregate` are bounded, config-free read-models over the\n// raw score rows (vxil.com/docs/api: the aggregate route), and\n// `?count=true` gives an exact \"entries ahead of me\" (\xA79.4).\n// \u2022 rate-limits \u2192 the abuse guard for PUBLIC score submission. Policies are\n// data on the feature's own REST surface (POST\n// /v1/rate-limits/policies + \u2026/check), not config leaves \u2014\n// see the README for the exact bodies.\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: {}, // defaults suffice \u2014 rankings are ad-hoc bounded reads, not config\n 'rate-limits': {}, // enabled with defaults; the submission policy is REST data\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 players: {\n singular: 'player',\n fields: {\n handle: { type: 'string', required: true, indexSlot: 's1', unique: true },\n display_name: { type: 'string', indexSlot: 's2' },\n country: { type: 'string', indexSlot: 's3' },\n },\n },\n // One row per RUN. Per-player \"best\" is derived at read time by\n // rank/aggregate (`max(score)` grouped by `player`) \u2014 nothing to keep\n // consistent on write.\n scores: {\n singular: 'score',\n fields: {\n // The ranked entity: rank/aggregate group by this field's s1 slot,\n // i.e. by the STORED value (for real submissions, the player item_id).\n player: { type: 'relation', relationTo: 'players', indexSlot: 's1' },\n game_mode: { type: 'string', required: true, indexSlot: 's2', validation: { enum: ['classic', 'blitz'] } },\n // n1 slot \u21D2 max/avg aggregates + `$gt` range (\"players ahead of me\").\n score: { type: 'int', required: true, indexSlot: 'n1', validation: { min: 0 } },\n // t1 slot \u21D2 `window: { field: 'achieved_at', sinceDays: 7 }` = weekly boards.\n achieved_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n seed: {\n // `vxil seed` POSTs these verbatim, in order \u2014 it cannot capture the\n // item_ids the player rows are assigned. The demo scores therefore carry\n // the players' stable HANDLES as the relation value (type-valid: a\n // relation stores an opaque string, and grouping ranks by the stored\n // value either way). Real submissions should store the item_id that\n // `create` returns \u2014 that is what makes `$expand` and dotted join\n // filters (e.g. `player.country`) resolve.\n cms: [\n {\n collection: 'players',\n items: [\n { handle: 'nova', display_name: 'Nova', country: 'JO' },\n { handle: 'rex', display_name: 'Rex', country: 'DE' },\n { handle: 'zed', display_name: 'Zed', country: 'JP' },\n ],\n },\n {\n collection: 'scores',\n items: [\n { player: 'nova', game_mode: 'classic', score: 12500, achieved_at: '2026-07-01T10:00:00Z' },\n { player: 'zed', game_mode: 'classic', score: 11200, achieved_at: '2026-07-02T09:15:00Z' },\n { player: 'rex', game_mode: 'classic', score: 9800, achieved_at: '2026-07-03T11:30:00Z' },\n { player: 'nova', game_mode: 'blitz', score: 4200, achieved_at: '2026-07-03T18:00:00Z' },\n { player: 'rex', game_mode: 'blitz', score: 5100, achieved_at: '2026-07-04T20:45:00Z' },\n ],\n },\n ],\n },\n});\n",
8653
+ "readme": '# Game Leaderboard template\n\nGame scores + live rankings in one typed `vxil.config.ts`. The leaderboards themselves are **never stored** \u2014\ntop-N, per-mode stats, and "players ahead of me" are all bounded reads over the raw score rows, so there are no\ncounters, crons, or denormalized tables to keep consistent.\n\n**Provisions:**\n- `players` \u2014 unique `handle`, `display_name`, `country`.\n- `scores` \u2014 `player` relation (the ranked entity), `game_mode` (enum), `score` (int, `min: 0`), `achieved_at`.\n One row per run; per-player "best" is derived at read time.\n- `rate-limits` \u2014 enabled as the abuse guard for public score submission (the policy is REST data, see below).\n\n**Apply it:**\n\n```bash\nvxil init --template leaderboard\nvxil quickstart # or: vxil link\nvxil push\nvxil seed # 3 players + 5 demo scores across 2 game modes\nvxil gen\n```\n\n**What to learn from this:**\n1. **Config-free leaderboards.** `POST /v1/cms/items/scores/rank` and `\u2026/aggregate` are bounded read-models\n (\u2264500 groups over a \u226450k-row scan; an over-wide scan fails `422 window_too_large` instead of silently\n truncating \u2014 vxil.com/docs/api, the aggregate route). `window: { field: \'achieved_at\', sinceDays: 7 }` = weekly boards.\n2. **`?count=true` is uncapped.** List pages clamp at the page-size limit, but a count is one\n `SELECT count(*)` under the same compiled filter (\xA79.4) \u2014 an exact "entries ahead of me" even at\n position 40,000. It counts score *rows*; for strictly "players ahead" keep one row per (player, mode)\n (patch on a new best), or read the rank endpoint (within its \xA712 bounds).\n3. **rate-limits as the abuse guard.** Public score submission is the classic spam target: create a named\n policy once, then check per player before accepting a run (vxil.com/docs/guide/06-feature-catalog: rate-limits).\n\n**Top-10 by best score per player in a mode** (`VXIL_BASE` = your edge, e.g. `https://api.vxil.com`):\n\n```bash\ncurl -sX POST "$VXIL_BASE/v1/cms/items/scores/rank" \\\n -H "Authorization: Bearer $VXIL_KEY" -H "content-type: application/json" \\\n -d \'{"groupBy":"player","metric":{"fn":"max","field":"score"},\n "rank":"rank","direction":"desc","filter":{"game_mode":"classic"},"limit":10}\'\n# \u2192 { "data": { "entries": [ { "key": { "player": "nova" }, "metric": 12500, "rank": 1 }, \u2026 ], "scanned": 3 } }\n```\n\n**Per-mode stats + a player\'s position** (typed SDK, after `vxil gen`):\n\n```ts\nconst { groups } = await vx.from(\'scores\').aggregate({\n aggregates: [{ fn: \'count\', as: \'runs\' }, { fn: \'max\', field: \'score\', as: \'top\' }],\n groupBy: [\'game_mode\'],\n});\nconst ahead = await vx.from(\'scores\').count({ game_mode: \'classic\', score: { $gt: 11200 } });\n// position = ahead + 1\n```\n\n**The submission guard** (once, from your server):\n\n```bash\ncurl -sX POST "$VXIL_BASE/v1/rate-limits/policies" \\\n -H "Authorization: Bearer $VXIL_KEY" -H "content-type: application/json" \\\n -d \'{"name":"scores.submit","key_template":"{player_id}","limit":10,"window_seconds":60,"behavior":"block"}\'\n# per run: POST /v1/rate-limits/check {"policy_id":"rl_\u2026","key_values":{"player_id":"<player item_id>"},"cost":1} \u2192 429 when exhausted\n```\n\n> The demo seed stores player *handles* as the `scores.player` relation value (a seed can\'t know generated\n> item_ids); real submissions should store the item_id `create` returns so `$expand`/dotted joins resolve.\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL) \xB7 vxil.com/docs/api (count, aggregate/rank) \xB7\nvxil.com/docs/guide/06-feature-catalog (rate-limits: policies, checks, per-identifier overrides) \xB7\n`examples/ecommerce/` (a bigger cms composition, with functions).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
8544
8654
  "functions": {}
8545
8655
  },
8546
8656
  {
@@ -8561,10 +8671,10 @@ export default defineConfig({
8561
8671
  "hasFunctions": true,
8562
8672
  "byoKeys": [],
8563
8673
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"IoT Fleet\" \u2014 device fleet telemetry + alerting, declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 devices \u2192 readings / alerts (resolved by relation)\n// \u2022 functions \u2192 the typed HTTP ingest endpoint + the daily aggregate rollup\n// \u2022 webhooks \u2192 fan alert writes out to the tenant's ops endpoint (outbound\n// subscriptions over the audit_event stream \u2014 webhooks.md \xA72)\n// Cross-row writes (create the reading + heartbeat the device + raise a\n// threshold alert) are exactly why ingest is a FUNCTION, not a Lane-A hook \u2014\n// hooks are pure single-row expressions. Everything here is DATA the tenant\n// owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // Every reading needs a metric name \u2014 a pure function of the row (Lane-A validate).\n reading_metric: {\n collection: 'readings',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.metric) > 0',\n message: 'a reading needs a metric name',\n },\n },\n // The DECLARATIVE rollup alternative to functions/daily-rollup.ts: a\n // `readModels` config entry (cms.md \xA712.4) can materialize the same\n // per-device aggregate on cron into a dedicated rollup collection. This\n // blueprint keeps the imperative function so you can read the endpoint\n // it rides on (POST /v1/cms/items/readings/aggregate, \xA712.2).\n },\n\n // Outbound subscriptions are RUNTIME rows, not config keys:\n // POST /v1/webhooks/subscriptions { target_url, event_prefixes: ['cms.item.'] }\n // fans every cms write (including new alert rows) to your ops endpoint as a\n // signed jobs callback \u2014 verify X-Vxil-Jobs-Signature, filter for alerts.\n webhooks: {},\n\n functions: { enabled: true }, // the \xA77.3 crossing: opt-in, egress-guarded\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n devices: {\n singular: 'device',\n fields: {\n serial: { type: 'string', required: true, indexSlot: 's1', unique: true },\n model: { type: 'string', indexSlot: 's2' },\n site: { type: 'string', indexSlot: 's3' },\n status: { type: 'string', indexSlot: 's4', validation: { enum: ['active', 'maintenance', 'retired'] } },\n last_seen: { type: 'datetime', indexSlot: 't1' }, // heartbeat, PATCHed by ingest-reading\n battery_pct: { type: 'int', indexSlot: 'n1', validation: { min: 0, max: 100 } },\n },\n },\n readings: {\n singular: 'reading',\n fields: {\n device: { type: 'relation', relationTo: 'devices', required: true, indexSlot: 's1' },\n metric: { type: 'string', required: true, indexSlot: 's2' }, // e.g. battery_pct, temp_c\n value: { type: 'float', required: true, indexSlot: 'n1' }, // n-slot \u2192 min/max/avg aggregates\n recorded_at: { type: 'datetime', indexSlot: 't1' }, // t-slot \u2192 the 24h aggregate window\n },\n },\n alerts: {\n singular: 'alert',\n fields: {\n device: { type: 'relation', relationTo: 'devices', required: true, indexSlot: 's1' },\n kind: { type: 'string', required: true, indexSlot: 's2' }, // low_battery | daily_rollup | \u2026\n severity: { type: 'string', indexSlot: 's3', validation: { enum: ['info', 'warning', 'critical'] } },\n raised_at: { type: 'datetime', indexSlot: 't1' },\n note: { type: 'text' },\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: tenant functions (functions.md) \u2500\u2500\n functions: {\n // The typed device endpoint: POST /v1/fn/ingest-reading { device_id, metric, value }\n // \u2192 create the reading + heartbeat the device + threshold-alert, in one call.\n // The alert write is deduped with a `lock` + `guard` WRITE BODY (cms.md \xA710):\n // at most ONE live low_battery alert per device \u2014 never a config key.\n 'ingest-reading': {\n entry: './functions/ingest-reading.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // pure vxil-internal; no external egress needed\n },\n // The daily rollup: ONE bounded \xA712.2 aggregate call (per-device min/max/avg\n // over the last 24h of battery_pct readings) \u2192 one info summary row per device\n // per UTC day (lock+guard-deduped \u2014 cron delivery is at-least-once).\n 'daily-rollup': {\n entry: './functions/daily-rollup.ts',\n trigger: { kind: 'cron', schedule: '0 6 * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'devices',\n items: [\n { serial: 'SN-0001', model: 'env-sensor-2', site: 'plant-a', status: 'active', battery_pct: 87 },\n { serial: 'SN-0002', model: 'env-sensor-2', site: 'plant-b', status: 'active', battery_pct: 42 },\n ],\n },\n ],\n },\n});\n",
8564
- "readme": '# IoT Fleet / Telemetry template\n\nA device-fleet backend \u2014 devices, telemetry readings, and alerts \u2014 declared end-to-end in one typed\n`vxil.config.ts`, with the domain logic that isn\'t config (ingest, thresholds, rollups) as two tenant\nfunctions.\n\n**What it provisions:**\n- `devices` \u2014 unique serial, model, site, lifecycle status, `last_seen` heartbeat, `battery_pct`.\n- `readings` \u2014 `device` relation, metric name, float `value` (n-slot \u2192 aggregates), `recorded_at` (t-slot \u2192 windows).\n- `alerts` \u2014 `device` relation, kind, severity, `raised_at`, free-text note.\n- Features: `cms` (+ a Lane-A validate hook), `webhooks` (outbound fan-out), `functions`\n (`ingest-reading` on http, `daily-rollup` on a daily cron).\n\n**Apply it:**\n\n```bash\nvxil init --template iot-fleet\nvxil quickstart # or `vxil link` an existing tenant\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **HTTP ingest functions as typed device endpoints** \u2014 `functions/ingest-reading.ts` turns one\n `POST /v1/fn/ingest-reading` (the key needs the `functions:invoke` scope) into three cross-row\n writes: create the reading, PATCH the device heartbeat, raise a threshold alert. Cross-row work\n is exactly why this is a function, not a hook.\n- **Guard-deduped threshold alerting** \u2014 the alert POST carries `lock` + `guard` in the WRITE BODY\n (`docs/features/cms.md` \xA710): at most one live `low_battery` alert per device, race-safe. Deleting\n the alert row resolves it and frees the guard.\n- **Bounded aggregates for rollups** \u2014 `functions/daily-rollup.ts` calls\n `POST /v1/cms/items/readings/aggregate` (\xA712.2: \u2264500 groups over a \u226450k scan) for per-device\n daily min/max/avg. Cron delivery is at-least-once, so each summary write is itself lock+guard-deduped\n to one per (device, day); the declarative sibling is a `readModels` config entry (\xA712.4).\n- **Webhook fan-out of alerts** \u2014 `POST /v1/webhooks/subscriptions` with\n `{ "target_url": "https://ops.example.com/hook", "event_prefixes": ["cms.item."] }` delivers every\n cms write (including new alerts) as a signed jobs callback \u2014 verify `X-Vxil-Jobs-Signature`.\n\n```bash\ncurl -X POST https://api.vxil.com/v1/fn/ingest-reading \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"device_id":"itm_...","metric":"battery_pct","value":12}\'\n```\n\nTyped SDK query for the ops dashboard: `vx.from(\'devices\').query({ filter: { battery_pct: { $lt: 20 } }, sort: \'-last_seen\' })`.\n\n**Go deeper:** `docs/features/cms.md` (\xA710 lock/guard, \xA712 aggregates/read-models),\n`docs/features/functions.md`, `docs/features/webhooks.md`, and `examples/ecommerce/` for a larger\nfunction saga. The config is yours after `init` \u2014 nothing is locked.\n',
8674
+ "readme": '# IoT Fleet / Telemetry template\n\nA device-fleet backend \u2014 devices, telemetry readings, and alerts \u2014 declared end-to-end in one typed\n`vxil.config.ts`, with the domain logic that isn\'t config (ingest, thresholds, rollups) as two tenant\nfunctions.\n\n**What it provisions:**\n- `devices` \u2014 unique serial, model, site, lifecycle status, `last_seen` heartbeat, `battery_pct`.\n- `readings` \u2014 `device` relation, metric name, float `value` (n-slot \u2192 aggregates), `recorded_at` (t-slot \u2192 windows).\n- `alerts` \u2014 `device` relation, kind, severity, `raised_at`, free-text note.\n- Features: `cms` (+ a Lane-A validate hook), `webhooks` (outbound fan-out), `functions`\n (`ingest-reading` on http, `daily-rollup` on a daily cron).\n\n**Apply it:**\n\n```bash\nvxil init --template iot-fleet\nvxil quickstart # or `vxil link` an existing tenant\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **HTTP ingest functions as typed device endpoints** \u2014 `functions/ingest-reading.ts` turns one\n `POST /v1/fn/ingest-reading` (the key needs the `functions:invoke` scope) into three cross-row\n writes: create the reading, PATCH the device heartbeat, raise a threshold alert. Cross-row work\n is exactly why this is a function, not a hook.\n- **Guard-deduped threshold alerting** \u2014 the alert POST carries `lock` + `guard` in the WRITE BODY\n (vxil.com/docs/api: the item write routes): at most one live `low_battery` alert per device, race-safe. Deleting\n the alert row resolves it and frees the guard.\n- **Bounded aggregates for rollups** \u2014 `functions/daily-rollup.ts` calls\n `POST /v1/cms/items/readings/aggregate` (\xA712.2: \u2264500 groups over a \u226450k scan) for per-device\n daily min/max/avg. Cron delivery is at-least-once, so each summary write is itself lock+guard-deduped\n to one per (device, day); the declarative sibling is a `readModels` config entry (\xA712.4).\n- **Webhook fan-out of alerts** \u2014 `POST /v1/webhooks/subscriptions` with\n `{ "target_url": "https://ops.example.com/hook", "event_prefixes": ["cms.item."] }` delivers every\n cms write (including new alerts) as a signed jobs callback \u2014 verify `X-Vxil-Jobs-Signature`.\n\n```bash\ncurl -X POST https://api.vxil.com/v1/fn/ingest-reading \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"device_id":"itm_...","metric":"battery_pct","value":12}\'\n```\n\nTyped SDK query for the ops dashboard: `vx.from(\'devices\').query({ filter: { battery_pct: { $lt: 20 } }, sort: \'-last_seen\' })`.\n\n**Go deeper:** vxil.com/docs/api (lock/guard, aggregates, read-models),\nvxil.com/docs/guide/08-running-your-code-functions, vxil.com/docs/guide/06-feature-catalog (webhooks), and `examples/ecommerce/` for a larger\nfunction saga. The config is yours after `init` \u2014 nothing is locked.\n',
8565
8675
  "functions": {
8566
- "daily-rollup.ts": "// daily-rollup.ts \u2014 BOUNDED AGGREGATES FOR ROLLUPS (a vxil function, cron trigger).\n//\n// Runs daily (cron 0 6 * * *). ONE call to the real group-by aggregate endpoint \u2014\n// POST /v1/cms/items/readings/aggregate (cms.md \xA712.2) \u2014 computes per-device\n// min/max/avg/count of battery_pct over the last 24h (grouped by the slot-bound\n// `device` relation; hard-bounded server-side: \u2264500 groups over a \u226450k-row scan,\n// else a clean 422 window_too_large), then writes one `daily_rollup` info row into\n// `alerts` per device. Cron delivery is at-least-once and cms does NOT dedupe on\n// the Idempotency-Key header, so exactly-once-per-day is enforced with the \xA710\n// lock+guard WRITE BODY: at most ONE daily_rollup per (device, UTC day) \u2014 a\n// redelivered run cleanly 409s. The DECLARATIVE alternative is a `readModels`\n// config entry materializing on cron (cms.md \xA712.4).\n\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string>; idempotency_key?: string }\ninterface Group { key: { device?: string }; count: number; min?: number; max?: number; avg?: number }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.org';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = new Date().toISOString();\n\n // 1. the bounded aggregate: min/max/avg need `value` on an n-slot (n1);\n // the 24h window rides the t-slot (t1) on `recorded_at`.\n const agg = await fetch(`${base}/v1/cms/items/readings/aggregate`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n aggregates: [\n { fn: 'min', field: 'value', as: 'min' },\n { fn: 'max', field: 'value', as: 'max' },\n { fn: 'avg', field: 'value', as: 'avg' },\n { fn: 'count' },\n ],\n groupBy: ['device'],\n filter: { metric: 'battery_pct', $status: 'published' },\n window: { field: 'recorded_at', sinceDays: 1 },\n limit: 500,\n }),\n });\n if (!agg.ok) return json({ error: 'aggregate_failed', status: agg.status }, 502);\n const groups = ((await agg.json()) as { data?: { groups?: Group[] } }).data?.groups ?? [];\n\n // 2. one published summary row per (device, UTC day) \u2014 the guard counts today's\n // live daily_rollup rows for the device under the lock, so a redelivered\n // cron run 409s instead of duplicating (cms.md \xA710).\n const day = now.slice(0, 10);\n let written = 0;\n for (const g of groups) {\n if (!g.key.device) continue;\n const r = await fetch(`${base}/v1/cms/items/alerts`, {\n method: 'POST',\n headers: env.idempotency_key ? { ...H, 'idempotency-key': `${env.idempotency_key}:${g.key.device}` } : H,\n body: JSON.stringify({\n status: 'published',\n lock: `rollup:${g.key.device}`,\n guard: {\n filter: { device: g.key.device, kind: 'daily_rollup', raised_at: { $gte: `${day}T00:00:00.000Z` } },\n max: 1,\n },\n data: {\n device: g.key.device, kind: 'daily_rollup', severity: 'info', raised_at: now,\n note: `battery_pct last 24h \u2014 min ${g.min} \xB7 max ${g.max} \xB7 avg ${round1(g.avg)} over ${g.count} readings`,\n },\n }),\n });\n if (r.ok) written += 1;\n }\n return json({ devices: groups.length, written }, 200);\n },\n};\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\nconst round1 = (n?: number) => (typeof n === 'number' ? Math.round(n * 10) / 10 : n);\n",
8567
- "ingest-reading.ts": "// ingest-reading.ts \u2014 THE TYPED DEVICE ENDPOINT (a vxil function, http trigger).\n//\n// POST /v1/fn/ingest-reading with { device_id, metric, value } \u2014 one call from a\n// device (or gateway) does the three cross-row writes no Lane-A hook may do:\n// 1. create the `readings` row (published, so the daily aggregate window sees it;\n// the envelope idempotency key is passed through as the write's Idempotency-Key\n// header \u2014 the platform convention, functions.md \xA76e)\n// 2. PATCH the device: last_seen = now (+ battery_pct when the metric carries it)\n// 3. THRESHOLD ALERTING: battery below 15 \u2192 create a low_battery alert, deduped\n// by a lock+guard WRITE BODY (at most ONE live alert per device \u2014 cms.md \xA710).\n// The guard is the dedupe that matters \u2014 a retried reading create is telemetry noise.\n// Resolve an alert by deleting its row; that frees the guard for the next one.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n idempotency_key?: string;\n // http-trigger: the caller's JSON body lands under `payload` (functions.md \xA72)\n payload?: { device_id?: string; metric?: string; value?: number };\n}\ninterface Created { data?: { item_id?: string } } // cms responses are { data: {\u2026}, meta }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.org';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const { device_id, metric } = env.payload ?? {};\n const value = Number(env.payload?.value);\n if (!device_id || !metric || !Number.isFinite(value)) {\n return json({ error: 'device_id, metric and numeric value required' }, 400);\n }\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = new Date().toISOString();\n\n // 0. the device must exist (404 from cms = unknown or deleted device)\n const dev = await fetch(`${base}/v1/cms/items/devices/${device_id}`, { headers: H });\n if (!dev.ok) return json({ error: 'unknown_device' }, 404);\n\n // 1. create the reading \u2014 published so the daily aggregate window sees it\n const rd = await fetch(`${base}/v1/cms/items/readings`, {\n method: 'POST',\n headers: env.idempotency_key ? { ...H, 'idempotency-key': env.idempotency_key } : H,\n body: JSON.stringify({\n status: 'published',\n data: { device: device_id, metric, value, recorded_at: now },\n }),\n });\n if (!rd.ok) return json({ error: 'reading_create_failed', status: rd.status }, 502);\n const reading = ((await rd.json()) as Created).data ?? {};\n\n // 2. heartbeat the device (merge-patch; battery only when this metric carries it)\n const patch: Record<string, unknown> = { last_seen: now };\n if (metric === 'battery_pct') patch.battery_pct = Math.max(0, Math.min(100, Math.round(value)));\n await fetch(`${base}/v1/cms/items/devices/${device_id}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data: patch }),\n });\n\n // 3. threshold alert \u2014 `guard` counts live rows under the ONE advisory `lock`,\n // so N racing low-battery ingests raise exactly one alert (the rest 409).\n let alert_id: string | undefined;\n if (metric === 'battery_pct' && value < 15) {\n const al = await fetch(`${base}/v1/cms/items/alerts`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n status: 'published',\n lock: `dev:${device_id}:low_battery`,\n guard: { filter: { device: device_id, kind: 'low_battery' }, max: 1 },\n data: { device: device_id, kind: 'low_battery', severity: 'warning', raised_at: now, note: `battery at ${value}%` },\n }),\n });\n if (al.ok) alert_id = ((await al.json()) as Created).data?.item_id;\n // 409 guard_failed \u2192 an open low_battery alert already exists; nothing to do.\n }\n\n return json({ reading_id: reading.item_id, alert_id }, 201);\n },\n};\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n"
8676
+ "daily-rollup.ts": "// daily-rollup.ts \u2014 BOUNDED AGGREGATES FOR ROLLUPS (a vxil function, cron trigger).\n//\n// Runs daily (cron 0 6 * * *). ONE call to the real group-by aggregate endpoint \u2014\n// POST /v1/cms/items/readings/aggregate (cms.md \xA712.2) \u2014 computes per-device\n// min/max/avg/count of battery_pct over the last 24h (grouped by the slot-bound\n// `device` relation; hard-bounded server-side: \u2264500 groups over a \u226450k-row scan,\n// else a clean 422 window_too_large), then writes one `daily_rollup` info row into\n// `alerts` per device. Cron delivery is at-least-once and cms does NOT dedupe on\n// the Idempotency-Key header, so exactly-once-per-day is enforced with the \xA710\n// lock+guard WRITE BODY: at most ONE daily_rollup per (device, UTC day) \u2014 a\n// redelivered run cleanly 409s. The DECLARATIVE alternative is a `readModels`\n// config entry materializing on cron (cms.md \xA712.4).\n\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string>; idempotency_key?: string }\ninterface Group { key: { device?: string }; count: number; min?: number; max?: number; avg?: number }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = new Date().toISOString();\n\n // 1. the bounded aggregate: min/max/avg need `value` on an n-slot (n1);\n // the 24h window rides the t-slot (t1) on `recorded_at`.\n const agg = await fetch(`${base}/v1/cms/items/readings/aggregate`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n aggregates: [\n { fn: 'min', field: 'value', as: 'min' },\n { fn: 'max', field: 'value', as: 'max' },\n { fn: 'avg', field: 'value', as: 'avg' },\n { fn: 'count' },\n ],\n groupBy: ['device'],\n filter: { metric: 'battery_pct', $status: 'published' },\n window: { field: 'recorded_at', sinceDays: 1 },\n limit: 500,\n }),\n });\n if (!agg.ok) return json({ error: 'aggregate_failed', status: agg.status }, 502);\n const groups = ((await agg.json()) as { data?: { groups?: Group[] } }).data?.groups ?? [];\n\n // 2. one published summary row per (device, UTC day) \u2014 the guard counts today's\n // live daily_rollup rows for the device under the lock, so a redelivered\n // cron run 409s instead of duplicating (cms.md \xA710).\n const day = now.slice(0, 10);\n let written = 0;\n for (const g of groups) {\n if (!g.key.device) continue;\n const r = await fetch(`${base}/v1/cms/items/alerts`, {\n method: 'POST',\n headers: env.idempotency_key ? { ...H, 'idempotency-key': `${env.idempotency_key}:${g.key.device}` } : H,\n body: JSON.stringify({\n status: 'published',\n lock: `rollup:${g.key.device}`,\n guard: {\n filter: { device: g.key.device, kind: 'daily_rollup', raised_at: { $gte: `${day}T00:00:00.000Z` } },\n max: 1,\n },\n data: {\n device: g.key.device, kind: 'daily_rollup', severity: 'info', raised_at: now,\n note: `battery_pct last 24h \u2014 min ${g.min} \xB7 max ${g.max} \xB7 avg ${round1(g.avg)} over ${g.count} readings`,\n },\n }),\n });\n if (r.ok) written += 1;\n }\n return json({ devices: groups.length, written }, 200);\n },\n};\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\nconst round1 = (n?: number) => (typeof n === 'number' ? Math.round(n * 10) / 10 : n);\n",
8677
+ "ingest-reading.ts": "// ingest-reading.ts \u2014 THE TYPED DEVICE ENDPOINT (a vxil function, http trigger).\n//\n// POST /v1/fn/ingest-reading with { device_id, metric, value } \u2014 one call from a\n// device (or gateway) does the three cross-row writes no Lane-A hook may do:\n// 1. create the `readings` row (published, so the daily aggregate window sees it;\n// the envelope idempotency key is passed through as the write's Idempotency-Key\n// header \u2014 the platform convention, functions.md \xA76e)\n// 2. PATCH the device: last_seen = now (+ battery_pct when the metric carries it)\n// 3. THRESHOLD ALERTING: battery below 15 \u2192 create a low_battery alert, deduped\n// by a lock+guard WRITE BODY (at most ONE live alert per device \u2014 cms.md \xA710).\n// The guard is the dedupe that matters \u2014 a retried reading create is telemetry noise.\n// Resolve an alert by deleting its row; that frees the guard for the next one.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n idempotency_key?: string;\n // http-trigger: the caller's JSON body lands under `payload` (functions.md \xA72)\n payload?: { device_id?: string; metric?: string; value?: number };\n}\ninterface Created { data?: { item_id?: string } } // cms responses are { data: {\u2026}, meta }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const { device_id, metric } = env.payload ?? {};\n const value = Number(env.payload?.value);\n if (!device_id || !metric || !Number.isFinite(value)) {\n return json({ error: 'device_id, metric and numeric value required' }, 400);\n }\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = new Date().toISOString();\n\n // 0. the device must exist (404 from cms = unknown or deleted device)\n const dev = await fetch(`${base}/v1/cms/items/devices/${device_id}`, { headers: H });\n if (!dev.ok) return json({ error: 'unknown_device' }, 404);\n\n // 1. create the reading \u2014 published so the daily aggregate window sees it\n const rd = await fetch(`${base}/v1/cms/items/readings`, {\n method: 'POST',\n headers: env.idempotency_key ? { ...H, 'idempotency-key': env.idempotency_key } : H,\n body: JSON.stringify({\n status: 'published',\n data: { device: device_id, metric, value, recorded_at: now },\n }),\n });\n if (!rd.ok) return json({ error: 'reading_create_failed', status: rd.status }, 502);\n const reading = ((await rd.json()) as Created).data ?? {};\n\n // 2. heartbeat the device (merge-patch; battery only when this metric carries it)\n const patch: Record<string, unknown> = { last_seen: now };\n if (metric === 'battery_pct') patch.battery_pct = Math.max(0, Math.min(100, Math.round(value)));\n await fetch(`${base}/v1/cms/items/devices/${device_id}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data: patch }),\n });\n\n // 3. threshold alert \u2014 `guard` counts live rows under the ONE advisory `lock`,\n // so N racing low-battery ingests raise exactly one alert (the rest 409).\n let alert_id: string | undefined;\n if (metric === 'battery_pct' && value < 15) {\n const al = await fetch(`${base}/v1/cms/items/alerts`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n status: 'published',\n lock: `dev:${device_id}:low_battery`,\n guard: { filter: { device: device_id, kind: 'low_battery' }, max: 1 },\n data: { device: device_id, kind: 'low_battery', severity: 'warning', raised_at: now, note: `battery at ${value}%` },\n }),\n });\n if (al.ok) alert_id = ((await al.json()) as Created).data?.item_id;\n // 409 guard_failed \u2192 an open low_battery alert already exists; nothing to do.\n }\n\n return json({ reading_id: reading.item_id, alert_id }, 201);\n },\n};\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n"
8568
8678
  }
8569
8679
  },
8570
8680
  {
@@ -8798,6 +8908,32 @@ const json = (o: unknown, status: number) => Response.json(o, { status });
8798
8908
  "heartbeat.ts": "// heartbeat.ts \u2014 THE SILENT-PROVIDER DETECTOR (a vxil function, cron trigger).\n//\n// Trigger: cron `0 7 * * *`. For each provider you list in PROVIDERS:\n// 1. read the newest PROCESSED production webhook events \u2014\n// `GET /v1/payments/webhook-events?provider=<p>&environment=production\n// &outcome=processed&limit=100` (newest first),\n// 2. days_since = now \u2212 the newest event's received_at,\n// 3. threshold = max(FLOOR_DAYS, ALERT_MULTIPLE \xD7 the MEDIAN gap between\n// those events) \u2014 your own cadence, not a number somebody picked,\n// 4. ok | stale | broken (or could_not_check when the read itself failed),\n// 5. compare with the stored row for that provider and post to Slack ONLY on\n// a crossing; a return to ok posts RESOLVED.\n//\n// Why the median and not the mean: one migration backfill or one Black Friday\n// inflates a mean for months. The median is what \"normal\" actually looks like.\n//\n// Why `?environment=production`: sandbox traffic is developer noise. A provider\n// can be chatty in test mode while production has been silent for a month \u2014\n// that is precisely the outage this function exists to catch.\n//\n// Run it by hand any time: `vxil functions invoke heartbeat`. It performs the\n// real check and returns the per-provider verdict as JSON; it still only posts\n// if a state genuinely crossed.\n\n// \u2500\u2500 Tune these. They are the whole policy. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n/** Providers to probe. Must be names the payments API accepts. Drop the ones\n * you do not use \u2014 a provider that has never sent an event is skipped anyway. */\nconst PROVIDERS = ['stripe', 'paddle', 'paypal', 'revenuecat'] as const;\n/** Alert once silence exceeds this multiple of your median inter-event gap. */\nconst ALERT_MULTIPLE = 3;\n/** \u2026but never sooner than this many days, however chatty your integration is.\n * A low-volume app can legitimately go a week without a single lifecycle event. */\nconst FLOOR_DAYS = 7;\n/** Past this multiple of the threshold, `stale` becomes `broken`. This split is\n * the blueprint's own choice \u2014 the SLO defines the alert threshold, not the\n * severity ladder \u2014 so move it wherever your escalation wants it. */\nconst BROKEN_MULTIPLE = 2;\n/** Gaps needed before a median means anything. Below this the floor is used. */\nconst MIN_GAPS_FOR_MEDIAN = 5;\n/** Events sampled per provider (the API caps a page at 100). */\nconst SAMPLE = 100;\n\ntype State = 'ok' | 'stale' | 'broken' | 'could_not_check';\n\ninterface Envelope {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n secrets?: Record<string, string>;\n}\ninterface WebhookEvent { event_id: string; provider: string; received_at: string | null }\ninterface StateData {\n provider: string; state?: State; detail?: string;\n days_since?: number; threshold_days?: number;\n last_event_at?: string; checked_at?: string; last_event_id?: string;\n}\ninterface Item { item_id: string; version?: number; data: StateData }\n\ninterface Verdict {\n provider: string;\n state: State;\n detail: string;\n days_since: number | null;\n threshold_days: number | null;\n last_event_at: string | null;\n last_event_id: string | null;\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const payments = env.scoped_jwts?.payments;\n const cms = env.scoped_jwts?.cms;\n const hook = env.secrets?.slack_webhook_url;\n if (!payments || !cms) return Response.json({ error: 'missing payments:read / cms scope' }, { status: 403 });\n if (!hook) return Response.json({ error: 'missing secret slack_webhook_url' }, { status: 409 });\n\n const store = new Store(base, cms);\n const checked: Verdict[] = [];\n const crossings: string[] = [];\n\n for (const provider of PROVIDERS) {\n const verdict = await check(base, payments, provider);\n if (!verdict) continue; // never sent an event \u2192 not part of this integration\n checked.push(verdict);\n const line = await reconcile(store, verdict);\n if (line) crossings.push(line);\n }\n\n let posted = 0;\n for (const text of crossings) {\n const r = await post(hook, text);\n if (r.ok) posted++;\n }\n return Response.json({ checked, crossings: crossings.length, posted });\n },\n};\n\n/** Measure one provider. `null` = the provider has never delivered a processed\n * production event, so there is no cadence to be silent against \u2014 the SLO's\n * \"once the tenant has ever received one\" precondition. */\nasync function check(base: string, jwt: string, provider: string): Promise<Verdict | null> {\n const url = `${base}/v1/payments/webhook-events`\n + `?provider=${encodeURIComponent(provider)}&environment=production&outcome=processed&limit=${SAMPLE}`;\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } }).catch(() => null);\n if (!res) {\n return verdict(provider, 'could_not_check', `could not reach the payments event log for ${provider}`);\n }\n if (!res.ok) {\n // 501 capability_not_enabled / 403 missing scope are configuration, not silence.\n return verdict(provider, 'could_not_check', `payments event log returned ${res.status} for ${provider}`);\n }\n const body = (await res.json().catch(() => ({}))) as { data?: { events?: WebhookEvent[] } };\n const events = (body.data?.events ?? []).filter((e) => e.received_at);\n if (events.length === 0) return null;\n\n // The API orders by received_at DESC, so [0] is the newest.\n const times = events.map((e) => Date.parse(e.received_at!)).filter(Number.isFinite).sort((a, b) => b - a);\n if (times.length === 0) return null;\n const last = times[0]!;\n const daysSince = round2((Date.now() - last) / 86_400_000);\n\n const gaps: number[] = [];\n for (let i = 0; i + 1 < times.length; i++) gaps.push((times[i]! - times[i + 1]!) / 86_400_000);\n const medianGap = gaps.length >= MIN_GAPS_FOR_MEDIAN ? median(gaps) : null;\n const threshold = round2(Math.max(FLOOR_DAYS, medianGap === null ? 0 : ALERT_MULTIPLE * medianGap));\n\n const state: State = daysSince <= threshold ? 'ok'\n : daysSince <= threshold * BROKEN_MULTIPLE ? 'stale'\n : 'broken';\n\n const cadence = medianGap === null\n ? `only ${gaps.length} gap(s) sampled \u2014 using the ${FLOOR_DAYS}-day floor`\n : `median gap ${round2(medianGap)}d \xD7 ${ALERT_MULTIPLE}, floor ${FLOOR_DAYS}d`;\n const detail = state === 'ok'\n ? `${provider}: last processed event ${daysSince}d ago (threshold ${threshold}d \u2014 ${cadence})`\n : `${provider} has sent no processed production event for ${daysSince}d \u2014 expected one within ${threshold}d (${cadence})`;\n\n return {\n provider, state, detail,\n days_since: daysSince,\n threshold_days: threshold,\n last_event_at: new Date(last).toISOString(),\n last_event_id: events[0]!.event_id ?? null,\n };\n}\n\n/** Persist the verdict; return a message ONLY when the state crossed. */\nasync function reconcile(store: Store, v: Verdict): Promise<string | null> {\n const now = new Date().toISOString();\n const data: StateData = {\n provider: v.provider, state: v.state, detail: v.detail, checked_at: now,\n ...(v.days_since !== null ? { days_since: v.days_since } : {}),\n ...(v.threshold_days !== null ? { threshold_days: v.threshold_days } : {}),\n ...(v.last_event_at ? { last_event_at: v.last_event_at } : {}),\n ...(v.last_event_id ? { last_event_id: v.last_event_id } : {}),\n };\n\n const current = await store.byProvider(v.provider);\n if (!current) {\n // First run. A healthy first sighting is remembered silently; an unhealthy\n // one is worth saying out loud immediately \u2014 you were already in the outage.\n const created = await store.create(data);\n return created && v.state !== 'ok' ? format(v) : null; // 409 \u21D2 a racing tick owns it\n }\n\n const prev = current.data.state ?? 'ok';\n if (prev === v.state) {\n // No crossing: refresh the measurement, stay quiet. A permanently-broken\n // provider therefore costs exactly one message, not one per day.\n await store.patch(current.item_id, current.version, data);\n return null;\n }\n // A CROSSING. If-Match makes exactly one of two overlapping ticks the winner.\n const ok = await store.patch(current.item_id, current.version, data);\n if (!ok) return null;\n return v.state === 'ok'\n ? `\u{1F7E2} RESOLVED: ${v.provider} is delivering again \u2014 last processed event ${v.days_since}d ago (was ${prev})`\n : format(v);\n}\n\nfunction format(v: Verdict): string {\n const dot = v.state === 'broken' ? '\u{1F534}' : v.state === 'stale' ? '\u{1F7E0}' : '\u{1F535}';\n const label = v.state === 'could_not_check' ? 'CHECK FAILED' : v.state.toUpperCase();\n return `${dot} [${label}] payments heartbeat \u2014 ${v.detail}`;\n}\n\n// \u2500\u2500 the cms state store (the REST envelope: { data: { items: [{item_id, version, data}] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() { return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' }; }\n async byProvider(provider: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ provider }));\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state?filter=${filter}&limit=1`, { headers: this.h() });\n if (!res.ok) return null;\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 \u2014 `provider` is unique, so a concurrent tick already claimed it. */\n async create(data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ status: 'published', data }),\n });\n return res.ok;\n }\n /** false on 409 version_conflict \u2014 another tick transitioned this provider first. */\n async patch(itemId: string, version: number | undefined, data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state/${itemId}`, {\n method: 'PATCH',\n headers: { ...this.h(), ...(version !== undefined ? { 'if-match': String(version) } : {}) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n\n// \u2500\u2500 the chat webhook: `text` is Slack's field, `content` is Discord's \u2014 send both.\n// (Duplicated from templates/alerts-to-slack on purpose: a blueprint is a\n// self-contained clone, and every file under functions/ must be a declared\n// entry point, so there is no place for a shared module to live.) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function post(url: string, text: string): Promise<{ ok: boolean; status: number }> {\n const res = await fetch(url, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, content: text }),\n }).catch(() => null);\n return { ok: Boolean(res?.ok), status: res?.status ?? 0 };\n}\n\nfunction verdict(provider: string, state: State, detail: string): Verdict {\n return { provider, state, detail, days_since: null, threshold_days: null, last_event_at: null, last_event_id: null };\n}\nfunction median(xs: number[]): number {\n const s = [...xs].sort((a, b) => a - b);\n const mid = s.length >> 1;\n return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2;\n}\nconst round2 = (n: number) => Math.round(n * 100) / 100;\n"
8799
8909
  }
8800
8910
  },
8911
+ {
8912
+ "id": "job-runner",
8913
+ "title": "Job Runner (queues \xB7 cron \xB7 generation \xB7 credits)",
8914
+ "vertical": "ops",
8915
+ "summary": "Every way work leaves the request path, and what happens when it fails \u2014 idempotent and deferred enqueues, cron schedules that report themselves late, a queue-triggered function as the worker, a long provider call vxil babysits while holding credits (reserve \u2192 settle \u2192 refund on the mock payments integration), dead-letter replay, and a cron drain that turns failure events into incident rows.",
8916
+ "collections": [
8917
+ "renders",
8918
+ "incidents"
8919
+ ],
8920
+ "features": [
8921
+ "jobs",
8922
+ "payments",
8923
+ "cms",
8924
+ "functions"
8925
+ ],
8926
+ "hasFunctions": true,
8927
+ "byoKeys": [
8928
+ "vxil_read_key"
8929
+ ],
8930
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Job Runner\" \u2014 the BACKGROUND-WORK blueprint. Four ways work leaves the\n// request path, and what happens when it fails:\n//\n// \u2022 enqueue \u2192 one-off work, deduplicated by an idempotency key, and\n// optionally deferred (seconds from now, or an exact time)\n// \u2022 schedules \u2192 cron, with a LATE schedule reporting itself\n// \u2022 queue trigger \u2192 a deployed function that IS the worker\n// \u2022 generation \u2192 a long provider call vxil babysits for you, holding\n// credits while it runs and releasing them when it ends\n// \u2022 the spine \u2192 every failure is an audit event; one cron function turns\n// the ones that matter into `incidents` rows\n//\n// The credits half is a payments INTEGRATION with the deterministic `mock`\n// provider, so the whole reserve \u2192 settle \u2192 refund story runs with no provider\n// account at all. \"Credits\" here are usage units, not money.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n // a failing delivery is retried this many times before it dead-letters\n retry: { defaultMaxAttempts: 3 },\n retention: { successfulRunDays: 7, failedRunDays: 30 },\n // once this many runs have dead-lettered today, a further failing run\n // skips its remaining retries and dead-letters immediately \u2014 a bound on\n // the churn ONE pathological target can generate. 0 = unlimited.\n dlqDailyQuota: 200,\n concurrency: { maxConcurrent: 5 },\n schedules: { maxPerTenant: 20 },\n generation: {\n maxConcurrent: 5,\n defaultTimeoutMs: 300_000, // 5 min unless the descriptor says otherwise\n pollMaxAttempts: 30, // give up after 30 polls \u2192 terminal fail\n maxReserveCredits: 200, // a single run can never hold more than this\n maxOutstandingReserveCredits: 5_000, // \u2026nor can all in-flight runs together\n },\n },\n\n // The ledger half. `mock` is the deterministic default provider: no keys,\n // no account, the entire credits path exercisable end to end. Point it at\n // your own Stripe/Paddle/PayPal/RevenueCat account when you go live \u2014 vxil\n // is never in the flow of funds.\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n // product id \u2192 what buying it grants. Consumed by the provider webhook\n // reducer; the map is DATA, so adding products never changes the config.\n productMap: {\n render_pack_1000: { creditType: 'render_credits', amount: 1000, period: 'once' },\n },\n // tier \u2192 what being on it entitles you to, and what it tops up monthly\n tierMap: {\n pro: {\n entitlements: ['render'],\n quotas: { renders_per_day: 500 },\n rank: 10,\n grants: [{ creditType: 'render_credits', amount: 5_000, period: 'monthly' }],\n },\n },\n // a generation that ends in failure gives its held credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {},\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n // The work items. One row per requested render.\n renders: {\n singular: 'render',\n fields: {\n // THE DEDUPE ANCHOR. Queue deliveries are at-least-once, so the\n // worker may see the same item twice; a unique field turns the second\n // write into a clean 409 the function treats as \"already done\".\n request_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n title: { type: 'string', indexSlot: 's2' },\n state: { type: 'string', indexSlot: 's3' }, // queued | processing | done | failed\n run_id: { type: 'string', indexSlot: 's4' }, // the jobs run that owns it\n credits: { type: 'int', indexSlot: 'n1' }, // held for this render\n created_at: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n\n // The failure memory. One row per incident KEY, so ten dead letters of\n // the same job are one row \u2014 plus the reserved `__cursor__` row holding\n // the audit-stream watermark.\n incidents: {\n singular: 'incident',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n event: { type: 'string', indexSlot: 's2' }, // the audit event that raised it\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n subject: { type: 'string', indexSlot: 's4' }, // job name / schedule id\n seen_count: { type: 'int', indexSlot: 'n1' },\n first_seen: { type: 'datetime', indexSlot: 't1' },\n last_seen: { type: 'datetime', indexSlot: 't2' },\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n detail: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // THE WORKER. A queue-triggered function is invoked by an enqueue, not by a\n // request \u2014 it has no URL a browser can reach. Delivery is at-least-once,\n // so it dedupes on `request_key` rather than assuming exactly-once.\n 'process-batch': {\n entry: './functions/process-batch.ts',\n trigger: { kind: 'queue', source: 'renders' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // THE FAILURE DRAIN. Every feature writes its lifecycle to your tenant's\n // audit stream; this reads the stream past a watermark every 5 minutes and\n // turns the failure events that matter into `incidents` rows.\n 'incident-watch': {\n entry: './functions/incident-watch.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' },\n scopes: ['cms:read', 'cms:write'],\n // The audit stream is a control-plane read, not one of the feature APIs\n // the function's scoped callback covers \u2014 so the drain reads it with the\n // narrowest key that can: one holding ONLY `features:read`.\n secrets: ['secret:vxil_read_key'],\n egressAllow: [],\n },\n },\n\n secrets: {\n vxil_read_key: {\n feature: 'functions',\n description: 'a vxil API key of this backend holding ONLY features:read \u2014 reads the audit stream',\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'renders',\n items: [\n {\n request_key: 'seed-0001',\n title: 'Quarterly report render',\n state: 'queued',\n credits: 10,\n created_at: '2026-04-01T08:00:00Z',\n notes: 'Seed row so the collection is not empty on first push.',\n },\n ],\n },\n ],\n },\n});\n",
8931
+ "readme": '# Job Runner (ops)\n\nFour ways work leaves the request path, and one answer for what happens when it fails. If you have\never written a `jobs` table, a worker loop, a retry counter and a "why did this run twice?" post\nmortem, this is that, declared.\n\n```bash\nvxil init --template job-runner\nvxil quickstart # or `vxil link <slug>`\nprintf \'%s\' "$READ_KEY" | vxil secrets set functions/vxil_read_key\nvxil push # collections + both functions\n```\n\n`vxil_read_key` is an API key **of this same backend** holding **only `features:read`** \u2014 the\nincident drain reads the audit stream with it (dashboard \u2192 API keys \u2192 create, tick `features:read`\nand nothing else). Everything else here needs no credentials at all: the payments integration runs\non the deterministic `mock` provider, so the whole credits story works before you have a provider\naccount. "Credits" are usage units you meter, not money and not stored value.\n\n## The four ways work leaves the request path\n\n| | You call | It runs | Use it when |\n|---|---|---|---|\n| **Enqueue** | `POST /v1/jobs/enqueue` | now, or later | one-off work, deduplicated by a key you choose |\n| **Schedule** | `POST /v1/jobs/schedules` | on a 5-field UTC cron | recurring work \u2014 and it tells you when it ran late |\n| **Queue trigger** | `vxil functions invoke <fn> --async` | a deployed function | the worker IS your code, with no URL to expose |\n| **Generation** | `POST /v1/jobs/generation` | a long provider call vxil babysits | a model/render/export that answers in minutes, not milliseconds |\n\nAnd one answer for failure: **every feature writes its lifecycle to your audit stream**, and the\n`incident-watch` cron turns the failures that matter into rows you can query.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `jobs:read jobs:write payments:read payments:write cms:read cms:write\nfunctions:read functions:invoke features:read`.\n\n**1. Enqueue the same thing twice.** The dedupe scope is `(job_name, idempotency_key)`:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deduplicated": true } } \u2190 the SAME run_id\n```\n\nNote where the key goes: **in the body**, as `idempotency_key`. (The `Idempotency-Key` *header* is\nwhat the notifications and payments surfaces read \u2014 jobs reads the field.) The window is 24 hours,\nand it is backstopped in the database, so two racing enqueues cannot both win.\n\n**2. Run it later.** Two mutually exclusive fields \u2014 pick one:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","delay_seconds":900}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deliver_after": "\u2026T12:26:12.686Z" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","deliver_after":"2026-12-24T09:00:00Z"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "delayed", "deliver_after": "2026-12-24T09:00:00.000Z" } }\n```\n\n`state` tells you which machinery is holding it: a short delay rides the queue itself and fires on\nthe second; anything beyond twelve hours is parked as `delayed` and released by a minute-tick\nsweep. The ceiling is 30 days. A `deliver_after` in the past is a 422, not a surprise.\n\n**3. A schedule that reports itself late.**\n\n```bash\nvxil api POST /v1/jobs/schedules --data \'{"job_name":"nightly.rollup","target_url":"https://hooks.example.com/rollup","cron":"0 2 * * *"}\'\n# 201 { "data": { "schedule_id": "sch_\u2026", "next_run_at": "2026-09-12T02:00:00.000Z", "state": "active" } }\n```\n\nFive UTC fields, with `*`, numbers, lists, ranges and steps \u2014 no month or weekday names, no `L`/`W`.\nWhen a tick finally fires more than **twice its own interval** late, the platform writes\n`jobs.schedule.missed` (once per crossing, not once per tick) and `jobs.schedule.recovered` when it\ncatches up. Step 7 turns both into rows.\n\n**4. The worker is your function.** `process-batch` is queue-triggered \u2014 it has no URL a browser can\nreach. Hand it a batch:\n\n```bash\nvxil functions invoke process-batch --async --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api GET "/v1/cms/items/renders?filter=%7B%22state%22%3A%22queued%22%7D"\n# 200 \u2026 two new rows, request_key "b-1:a" and "b-1:b"\n```\n\n(`--async` enqueues it; drop the flag to call the same function synchronously and\nread its answer. `--async` needs a linked project \u2014 `vxil quickstart` or `vxil link <slug>`.)\n\nNow send the **exact same batch again**, synchronously so you can read the verdict:\n\n```bash\nvxil functions invoke process-batch --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# { "batch_id": "b-1", "created": 0, "duplicates": 2, "rejected": 0 }\n```\n\nNothing was duplicated, and the function contains no dedupe logic. `request_key` is declared\n`unique`, so the second create is a 409 \u2014 and the function reads a 409 as *already done*. That is\nthe whole strategy: **delivery is at-least-once, so correctness lives in a declared field, not in a\nhope that it runs once.**\n\n**5. Credits: reserve \u2192 settle.** Grant some usage units first (the header is required here \u2014 this\nis the money-shaped surface):\n\n```bash\ncurl -s -X POST "https://api.vxil.com/v1/payments/credits/grant" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -H \'idempotency-key: seed-1\' \\\n -d \'{"user_id":"u_demo","credit_type":"render_credits","amount":100,"source":"seed"}\'\n# 200 { "data": { "balance_after": 100, "ledger_entry_id": null } }\n\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "credit_type": "render_credits", "balance": 100, "held": 0, "available": 100, \u2026 } }\n```\n\nNow start a generation that holds ten of them while it runs:\n\n```bash\nvxil api POST /v1/jobs/generation --data \'{\n "job_name": "render.deck",\n "provider": { "url": "https://api.your-render-provider.example/v1/renders",\n "method": "POST",\n "body": { "format": "pdf" } },\n "completion": { "mode": "poll",\n "status_path": "status",\n "poll": { "url": "https://api.your-render-provider.example/v1/renders/latest",\n "method": "GET", "interval_ms": 5000 } },\n "reserve_credits": { "amount": 10, "user_id": "u_demo",\n "credit_type": "render_credits", "reason": "deck render" },\n "timeout": { "after_ms": 120000 },\n "payload": { "render_id": "b-1:a" }\n}\'\n# 202 { "data": { "run_id": "run_\u2026", "generation_status": "pending", "state": "queued" } }\n```\n\nImmediately, the balance moves \u2014 but only the **held** half:\n\n```bash\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "balance": 100, "held": 10, "available": 90, \u2026 } }\n\nvxil api GET "/v1/payments/usage?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "user_id": "u_demo", "entries": [\n# { "kind": "consume", "state": "provisional", "delta": -10, "job_id": "run_\u2026", "source": "deck render", \u2026 },\n# { "kind": "grant", "state": "committed", "delta": 100, \u2026 } ] } }\n```\n\n`state: "provisional"` is the reservation. Nothing has been spent yet \u2014 `balance` is untouched and\n`available` dropped, so the same user cannot start ten more renders on credits they do not have.\n\nWhen the run reaches a terminal state, vxil settles it for you, and the ledger says which way it\nwent:\n\n- **completed** \u2192 the provisional row flips to `committed` and a `settle` row lands with\n `source: "job:succeeded"`. The credits were spent.\n- **failed / timed out** \u2192 a `reversal` row lands with a **positive** delta and\n `source: "job:failed"`, the original flips to `reversed`, and `held` returns to zero. The\n customer was not charged for work that did not happen. That refund is the\n `ledger.autoRefundOnJobFailure` flag in `vxil.config.ts`; turn it off and a failure still releases\n the hold but keeps the charge.\n\nTwo ceilings you do not have to remember to set: a single run\'s request is **clamped** to\n`generation.maxReserveCredits` (never rejected, so a bad caller cannot break the flow), and the sum\nof all outstanding holds is refused past `generation.maxOutstandingReserveCredits` with a 429. Both\nhave safe defaults.\n\nPoll mode is exactly what it says: vxil calls your provider once, then re-reads\n`completion.poll.url` every `interval_ms`, reading `status_path` out of the body. Anything it does\nnot recognise counts as *still processing* \u2014 a generation is never silently completed by a typo.\nAfter `pollMaxAttempts` it fails terminally, and the timeout does the same on the wall clock.\n\n**6. Dead letters and replay.** There is no separate dead-letter inbox \u2014 a dead letter is a run in\nthe `dead` state:\n\n```bash\nvxil api GET "/v1/jobs/runs?state=dead&limit=20"\n# 200 { "data": { "runs": [ { "run_id": "run_\u2026", "job_name": "report.email", "state": "dead",\n# "attempt_number": 1, "max_attempts": 1,\n# "last_error_class": "NonRetryableHttp",\n# "last_error_msg": "target returned 404", \u2026 } ] } }\n\nvxil api POST /v1/jobs/runs/run_\u2026/replay\n# 202 { "data": { "run_id": "run_NEW", "replayed_from": "run_\u2026", "state": "queued" } }\n```\n\nReplay takes **no body** and clones the original into a *new* run \u2014 the dead row is evidence and\nstays untouched. Only terminal runs replay; anything still in flight is a `409 not_replayable`.\n\n**7. Failures become rows.** `incident-watch` runs every five minutes. Force it once:\n\n```bash\nvxil functions invoke incident-watch\n# first run: { "initialized": true, "cursor": "\u2026", "raised": 0 } \u2190 history never floods you\n# after a dead letter, the next run:\n# { "scanned": 12, "raised": 1, "resolved": 0, "cursor": "\u2026" }\n\nvxil api GET /v1/cms/items/incidents\n# 200 \u2026 { "key": "jobs:dead-letter:report.email", "event": "job.dead_lettered", "level": "error",\n# "subject": "report.email", "seen_count": 1, "first_seen": "\u2026", "last_seen": "\u2026",\n# "detail": "run run_\u2026 died after attempt 1" }\n```\n\nTen dead letters of the same job produce **one** row with `seen_count: 10`, because the incident\nkey collapses them. `jobs.schedule.missed` and `jobs.schedule.recovered` share a key, so a schedule\nthat catches up closes its own incident. The `RULES` table in `functions/incident-watch.ts` is the\nallow-list \u2014 plain data. Widen it from the catalog of everything the platform can emit:\n\n```bash\nvxil api GET /v1/webhooks/events/catalog\n# 200 { "data": { "count": 170, "events": [ { "name": "job.dead_lettered", "feature": "jobs",\n# "level": "failure", "payload_keys": [ \u2026 ] } \u2026 ],\n# "prefixes": [ { "prefix": "payments.", "count": 21 }, \u2026 ] } } \u2190 34 prefixes\n```\n\nIf you would rather the same events went to **your own** endpoint than into a collection, subscribe\nto the spine directly \u2014 same events, different consumer:\n\n```bash\nvxil api POST /v1/webhooks/subscriptions --data \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["job.","jobs."]}\'\n```\n\n## What to learn from this\n\n- **Exactly-once is not a delivery guarantee you can buy; it is a field you declare.** `unique` on\n `request_key`, `idempotency_key` on the enqueue, `foreign_id` elsewhere \u2014 each converts\n at-least-once delivery into an at-most-once *effect*.\n- **A reservation is not a charge.** Holding credits while long work runs, and releasing them if it\n fails, is the difference between metering and billing people for your outages. The ledger shows\n both halves, so you can answer "why was I charged?" from a query.\n- **Failure needs a vocabulary, not a log.** `job.dead_lettered`, `jobs.schedule.missed`,\n `job.generation.failed` are named events with stable payloads \u2014 that is why a 40-line function can\n turn them into an incident board, and why the catalog route can tell an agent what exists.\n- **Late is a different failure from broken.** A schedule that fires twice its interval late says so\n once, and says so again when it recovers. Alerting on every tick teaches people to mute you.\n- **The clamp beats the rejection.** Capping a requested hold, rather than refusing it, keeps a\n careless caller from breaking the flow while still bounding the blast radius.\n\n**Pairs with:** `templates/alerts-to-slack/` (the same drain, routed to a chat channel instead of a\ncollection) and `templates/payments-heartbeat/` (noticing the failure that is *silence*).\n',
8932
+ "functions": {
8933
+ "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\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> }\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n secrets?: Record<string, string>;\n payload?: { dry_run?: boolean };\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 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",
8934
+ "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\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n idempotency_key?: string;\n payload?: { batch_id?: string; items?: Array<{ key?: string; title?: string; credits?: number }> };\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 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"
8935
+ }
8936
+ },
8801
8937
  {
8802
8938
  "id": "research-library",
8803
8939
  "title": "Research Library",
@@ -8824,7 +8960,7 @@ const json = (o: unknown, status: number) => Response.json(o, { status });
8824
8960
  // \u2022 notifications \u2192 optional digests/alerts
8825
8961
  // Everything is DATA the tenant owns and edits after \`vxil init\`. There is no
8826
8962
  // platform lock over the shape \u2014 the invariants (a required title, a sane year)
8827
- // ride as tenant-owned Lane-A validate hooks. See docs/cms-content-templates-analysis.md.
8963
+ // ride as tenant-owned Lane-A validate hooks.
8828
8964
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
8829
8965
  export default defineConfig({
8830
8966
  env: 'staging',
@@ -8910,7 +9046,7 @@ export default defineConfig({
8910
9046
  },
8911
9047
  });
8912
9048
  `,
8913
- "readme": '# Research Library template\n\nA knowledge base for research/reference content \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions (all `cms` collections you own and can edit \u2014 all `public: true`):**\n- `topics` \u2014 subject areas (name, slug, description).\n- `sources` \u2014 cited works (title, url, author, kind, year \u2192 `topics`); a Lane-A hook keeps the year sane.\n- `notes` \u2014 your writing, cross-linked to a `source` and a `topic` (slot-bound so you can filter notes by either \u2014 the single-hop join in `docs/features/cms.md` \xA712.1). `tags` is a JSON array (`$arrayContains`-searchable).\n\nAll three are **`public: true`** \u2014 a published research library reads over the keyless public-delivery lane\n(see below); keep a note or source as a DRAFT while it is a work-in-progress and it stays private.\n\n**Use it:**\n\n```bash\nvxil init --template research-library\nvxil quickstart # a new backend (or `vxil link <slug>` for an existing one)\nvxil push # apply the collections + hooks\nvxil gen # typed SDK + MCP catalog\n```\n\n**What to learn from this:**\n- **A bounded citation graph** \u2014 `notes.source`/`notes.topic` are slot-bound relations, so a note list can\n filter one hop into the target collection: the \xA712.1 single-hop dotted-key join.\n- **Lane-A validate as a tenant-owned invariant** \u2014 the "sane year" rule is an AST-checked safe expression in\n YOUR config, run inside the write transaction (`docs/features/cms.md` \xA77).\n- **`json` tags** \u2014 unslotted but `$arrayContains`-searchable \u2014 the platform indexes it for you (\xA73).\n\n```bash\n# every note whose cited source is a book, with a server key \u2014 the single-hop dotted join (\xA712.1)\ncurl -G "https://api.vxil.com/v1/cms/items/notes" \\\n -H "Authorization: Bearer $VXIL_API_KEY" \\\n --data-urlencode \'filter={"source.kind":"book"}\'\n```\n\n**The public library \u2014 keyless** (`docs/features/cms.md` \xA716). Because every collection is `public: true`, a\nreader front-end serves the published library with **no API key** \u2014 the edge forces `status = \'published\'`\n(drafts stay private), edge-caches the response, and serves the safe query subset (`filter`/`sort`/`limit`).\n\n```bash\n# the public citation index \u2014 NO api key\ncurl "https://api.vxil.com/v1/cms/public/$TENANT/sources?filter=%7B%22kind%22%3A%22book%22%7D&limit=50"\n```\n\n**Go deeper:** `docs/features/cms.md` (\xA73 \xB7 \xA77 \xB7 \xA712 \xB7 **\xA716 public delivery**) \xB7 `templates/docs-site/`\n(a pure public-content site) \xB7 `templates/blog/` \xB7 `templates/notes/` \xB7 `docs/cms-content-templates-analysis.md`.\n\n**Own the shape.** After `init` the config is yours \u2014 add fields, edit the hook, add collections. Nothing is\nlocked; the "sane year" invariant rides as a tenant-owned Lane-A `validate` hook you can see and change (the\nrequired titles are plain `required: true` field flags). To narrow the public surface, drop `public: true`\nfrom any collection you want to keep behind an API key \u2014 the flag is per-collection.\n',
9049
+ "readme": '# Research Library template\n\nA knowledge base for research/reference content \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions (all `cms` collections you own and can edit \u2014 all `public: true`):**\n- `topics` \u2014 subject areas (name, slug, description).\n- `sources` \u2014 cited works (title, url, author, kind, year \u2192 `topics`); a Lane-A hook keeps the year sane.\n- `notes` \u2014 your writing, cross-linked to a `source` and a `topic` (slot-bound so you can filter notes by either \u2014 the single-hop join in vxil.com/docs/guide/04-data-with-cms). `tags` is a JSON array (`$arrayContains`-searchable).\n\nAll three are **`public: true`** \u2014 a published research library reads over the keyless public-delivery lane\n(see below); keep a note or source as a DRAFT while it is a work-in-progress and it stays private.\n\n**Use it:**\n\n```bash\nvxil init --template research-library\nvxil quickstart # a new backend (or `vxil link <slug>` for an existing one)\nvxil push # apply the collections + hooks\nvxil gen # typed SDK + MCP catalog\n```\n\n**What to learn from this:**\n- **A bounded citation graph** \u2014 `notes.source`/`notes.topic` are slot-bound relations, so a note list can\n filter one hop into the target collection: the \xA712.1 single-hop dotted-key join.\n- **Lane-A validate as a tenant-owned invariant** \u2014 the "sane year" rule is an AST-checked safe expression in\n YOUR config, run inside the write transaction (vxil.com/docs/guide/07-validation-and-hooks).\n- **`json` tags** \u2014 unslotted but `$arrayContains`-searchable \u2014 the platform indexes it for you (\xA73).\n\n```bash\n# every note whose cited source is a book, with a server key \u2014 the single-hop dotted join (\xA712.1)\ncurl -G "https://api.vxil.com/v1/cms/items/notes" \\\n -H "Authorization: Bearer $VXIL_API_KEY" \\\n --data-urlencode \'filter={"source.kind":"book"}\'\n```\n\n**The public library \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because every collection is `public: true`, a\nreader front-end serves the published library with **no API key** \u2014 the edge forces `status = \'published\'`\n(drafts stay private), edge-caches the response, and serves the safe query subset (`filter`/`sort`/`limit`).\n\n```bash\n# the public citation index \u2014 NO api key\ncurl "https://api.vxil.com/v1/cms/public/$TENANT/sources?filter=%7B%22kind%22%3A%22book%22%7D&limit=50"\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (queries \xB7 joins \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7 `templates/docs-site/`\n(a pure public-content site) \xB7 `templates/blog/` \xB7 `templates/notes/`.\n\n**Own the shape.** After `init` the config is yours \u2014 add fields, edit the hook, add collections. Nothing is\nlocked; the "sane year" invariant rides as a tenant-owned Lane-A `validate` hook you can see and change (the\nrequired titles are plain `required: true` field flags). To narrow the public surface, drop `public: true`\nfrom any collection you want to keep behind an API key \u2014 the flag is per-collection.\n',
8914
9050
  "functions": {}
8915
9051
  },
8916
9052
  {
@@ -8929,7 +9065,7 @@ export default defineConfig({
8929
9065
  "hasFunctions": false,
8930
9066
  "byoKeys": [],
8931
9067
  "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// \"Gallery\" \u2014 an image / media gallery (albums of photos), declared end-to-end in\n// ONE typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 albums \u2192 photos (resolved by relation)\n// \u2022 files \u2192 the actual image bytes (managed object storage + signed shared links)\n// The `file`-typed fields store a files-feature object ref; the item stays a\n// bounded JSON document. Everything here is DATA the tenant owns and edits.\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: { draftPublish: true },\n files: { enabled: true }, // managed object storage + shared links for the images\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 albums: {\n singular: 'album',\n // PUBLIC DELIVERY (cms.md \xA716): a public gallery reads keyless \u2014 published\n // albums over GET /v1/cms/public/:tenantId/albums, edge-cached.\n public: true,\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n cover: { type: 'file' }, // a files-feature object ref (the album cover)\n description: { type: 'text' },\n },\n },\n photos: {\n singular: 'photo',\n // PUBLIC DELIVERY (cms.md \xA716): published photos read with NO API key. The\n // `image`/`cover` fields carry a files OBJECT REF (obj_\u2026), not bytes \u2014 the\n // public row exposes the ref; the reader resolves the actual image through a\n // files SHARED LINK (files.md), so the stored object stays access-controlled.\n public: true,\n fields: {\n title: { type: 'string', indexSlot: 's1' },\n image: { type: 'file', required: true }, // the photo bytes (via `files`)\n album: { type: 'relation', relationTo: 'albums', indexSlot: 's2' },\n taken_at: { type: 'datetime', indexSlot: 't1' },\n width: { type: 'int', indexSlot: 'n1' },\n height: { type: 'int', indexSlot: 'n2' },\n tags: { type: 'json' }, // [\"landscape\",\"2024\"] \u2014 $arrayContains-searchable\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'albums',\n items: [\n { title: 'Travels', slug: 'travels', description: 'Photos from the road.' },\n ],\n },\n ],\n },\n});\n",
8932
- "readme": "# Gallery template\n\nAn image / media gallery \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `albums` \u2014 a titled, slugged collection with a `cover` image (a `files` object ref) and description.\n **`public: true`** \u2014 a public gallery reads keyless.\n- `photos` \u2014 a `file`-typed `image` (the bytes live in `files`), linked to an `album`, with `taken_at`,\n `width`/`height`, and JSON `tags`. The `album` relation is slot-bound so you can list a single album's photos.\n **`public: true`** \u2014 published photos read keyless; the `image` field carries a files object ref (`obj_\u2026`),\n so the reader resolves the bytes through a files **shared link** (the stored object stays access-controlled).\n\n**Use it:**\n\n```bash\nvxil init --template gallery\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **`file` fields hold refs, not bytes** \u2014 `cover`/`image` store a `files`-feature object id (`obj_\u2026`); upload\n the bytes through `files` (managed storage + signed shared links) and the cms item stays a bounded JSON document.\n- **A slot-bound relation is the album view** \u2014 `photos.album` rides `s2`, so \"this album's photos\" is one\n indexed filter (`docs/features/cms.md` \xA73).\n- **Numeric slots buy range queries** \u2014 `width`/`height` on `n1`/`n2` make dimension filters index-served.\n\n```ts\n// one album's photos with a server key, newest first\nconst { items } = await vx.from('photos').query({\n filter: { album: albumId, $status: 'published' }, sort: '-taken_at', limit: 24,\n});\n```\n\n**The public gallery \u2014 keyless** (`docs/features/cms.md` \xA716). `albums`/`photos` are `public: true`, so a\nstatic gallery front-end reads with **no API key** (published-only, edge-cached). The photo rows carry a\nfiles object ref, not bytes \u2014 resolve each through a files shared link (`docs/features/files.md`), so the stored\nobject stays access-controlled even though the metadata is public.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// one album's public photos \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'photos', { filter: { album: albumId }, sort: '-taken_at' });\n```\n\n**Go deeper:** `docs/features/files.md` (upload + shared links) \xB7 `docs/features/cms.md` (\xA73 \xB7 **\xA716 public\ndelivery**) \xB7 `templates/docs-site/` (a pure public-content site) \xB7 `templates/blog/` \xB7 `templates/directory/`.\n\n**Own the shape.** The config is yours after `init` \u2014 add EXIF fields, a `photographer` relation, more albums.\nNothing is locked.\n",
9068
+ "readme": "# Gallery template\n\nAn image / media gallery \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `albums` \u2014 a titled, slugged collection with a `cover` image (a `files` object ref) and description.\n **`public: true`** \u2014 a public gallery reads keyless.\n- `photos` \u2014 a `file`-typed `image` (the bytes live in `files`), linked to an `album`, with `taken_at`,\n `width`/`height`, and JSON `tags`. The `album` relation is slot-bound so you can list a single album's photos.\n **`public: true`** \u2014 published photos read keyless; the `image` field carries a files object ref (`obj_\u2026`),\n so the reader resolves the bytes through a files **shared link** (the stored object stays access-controlled).\n\n**Use it:**\n\n```bash\nvxil init --template gallery\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **`file` fields hold refs, not bytes** \u2014 `cover`/`image` store a `files`-feature object id (`obj_\u2026`); upload\n the bytes through `files` (managed storage + signed shared links) and the cms item stays a bounded JSON document.\n- **A slot-bound relation is the album view** \u2014 `photos.album` rides `s2`, so \"this album's photos\" is one\n indexed filter (vxil.com/docs/guide/04-data-with-cms).\n- **Numeric slots buy range queries** \u2014 `width`/`height` on `n1`/`n2` make dimension filters index-served.\n\n```ts\n// one album's photos with a server key, newest first\nconst { items } = await vx.from('photos').query({\n filter: { album: albumId, $status: 'published' }, sort: '-taken_at', limit: 24,\n});\n```\n\n**The public gallery \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). `albums`/`photos` are `public: true`, so a\nstatic gallery front-end reads with **no API key** (published-only, edge-cached). The photo rows carry a\nfiles object ref, not bytes \u2014 resolve each through a files shared link (vxil.com/docs/guide/06-feature-catalog), so the stored\nobject stays access-controlled even though the metadata is public.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// one album's public photos \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'photos', { filter: { album: albumId }, sort: '-taken_at' });\n```\n\n**Go deeper:** vxil.com/docs/guide/06-feature-catalog (files: upload + shared links) \xB7 vxil.com/docs/guide/04-data-with-cms (queries \xB7 **public\ndelivery**) \xB7 `templates/docs-site/` (a pure public-content site) \xB7 `templates/blog/` \xB7 `templates/directory/`.\n\n**Own the shape.** The config is yours after `init` \u2014 add EXIF fields, a `photographer` relation, more albums.\nNothing is locked.\n",
8933
9069
  "functions": {}
8934
9070
  },
8935
9071
  {
@@ -8947,8 +9083,8 @@ export default defineConfig({
8947
9083
  ],
8948
9084
  "hasFunctions": false,
8949
9085
  "byoKeys": [],
8950
- "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// \"Directory\" \u2014 a listings directory (categorized listings people can submit),\n// declared end-to-end in ONE typed file. A BLUEPRINT composing shipped blocks \u2014\n// \u2022 cms \u2192 categories \u2192 listings (resolved by relation)\n// \u2022 auth \u2192 accounts, so a submitter is a verified end-user\n// `listings.submitted_by` is the end-user OWNER field: in end-user mode a\n// submitter can only edit their OWN listing (a no-op for server callers). This\n// is one declarative flag, not a per-user policy. Everything here is DATA the\n// 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 draftPublish: true, // submissions land as drafts; an admin publishes them\n hooks: {\n // Every listing needs a name \u2014 a pure function of the row (Lane-A validate).\n listing_name: {\n collection: 'listings',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.name) > 0',\n message: 'a listing needs a name',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // submitters sign in as end-users\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 categories: {\n singular: 'category',\n // PUBLIC DELIVERY (cms.md \xA716): the category nav reads keyless too.\n public: true,\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n },\n },\n listings: {\n singular: 'listing',\n // End-user owner-scope (docs/features/cms.md \xA715): a verified submitter may\n // only read/edit their OWN listing. Server callers are unaffected.\n ownerField: 'submitted_by',\n // PUBLIC DELIVERY (cms.md \xA716): the PUBLIC BROWSE VIEW \u2014 published listings\n // read with NO API key over GET /v1/cms/public/:tenantId/listings, and the\n // `submitted_by` owner field is STRIPPED from every served row (a visitor\n // never sees who submitted it). Owner-scoping (above) still governs the\n // authed edit lane \u2014 the two lanes are independent by design.\n public: true,\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n category: { type: 'relation', relationTo: 'categories', indexSlot: 's3' },\n submitted_by: { type: 'string', indexSlot: 's4' }, // the owner (end-user) id\n description: { type: 'text' },\n website: { type: 'string' }, // stored, not indexed\n location: { type: 'string' }, // stored, not indexed\n featured: { type: 'bool' },\n rating: { type: 'float', indexSlot: 'n1' },\n submitted_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'categories',\n items: [\n { name: 'Restaurants', slug: 'restaurants' },\n { name: 'Services', slug: 'services' },\n ],\n },\n ],\n },\n});\n",
8951
- "readme": "# Listings Directory template\n\nA categorized listings directory (people submit entries) \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `categories` \u2014 name, unique slug. **`public: true`** \u2014 the category nav reads keyless.\n- `listings` \u2014 name, unique slug, `category` relation, `submitted_by` (the **owner field**), description,\n website, location, `featured`, `rating`, `submitted_at`. A Lane-A hook requires a name; `draftPublish` lets\n submissions land as drafts an admin publishes. **`public: true`** \u2014 the public browse view reads keyless,\n and `submitted_by` is stripped from every served row (a visitor never sees who submitted it).\n- `auth` (email/password) \u2014 so a submitter is a **verified end-user**.\n\n**Use it:**\n\n```bash\nvxil init --template directory\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **Owner-scoping is one declarative flag** \u2014 `ownerField: 'submitted_by'` (a real slot-bound string field):\n in end-user mode a submitter can only read/edit their **own** listing; server callers are unaffected\n (`docs/features/cms.md` \xA715).\n- **Moderation is the lifecycle** \u2014 submissions land as drafts, an admin `publish` makes them public, and the\n public browse view is served over the keyless public-delivery lane (below).\n- **The name invariant is a Lane-A hook** \u2014 tenant-owned data you can edit after `init` (\xA77).\n\n```ts\n// the browse view with a server key: published listings in one category, best-rated first\nconst { items } = await vx.from('listings').query({\n filter: { category: categoryId, $status: 'published' }, sort: '-rating', limit: 25,\n});\n```\n\n**The public browse view \u2014 keyless** (`docs/features/cms.md` \xA716). Because `listings`/`categories` are\n`public: true`, the public directory reads with **no API key** \u2014 the edge forces `status = 'published'`\n(pending submissions stay private until an admin publishes), edge-caches the page, and **strips the\n`submitted_by` owner field** from every row. Owner-scoping (\xA715) still governs the authed edit lane \u2014 the\ntwo lanes are independent.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public browse view \u2014 NO api key, submitted_by stripped\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'listings', { filter: { 'category.slug': 'restaurants' }, sort: '-rating' });\n```\n\n**Go deeper:** `docs/features/cms.md` (\xA77 hooks \xB7 \xA715 owner-scoping \xB7 **\xA716 public delivery**) \xB7\n`docs/features/auth.md` \xB7 `examples/feedback-board/` (a voting board with a Lane-A hook + an HTTP function) \xB7\n`templates/docs-site/` (a pure public-content site) \xB7 `templates/catalog/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
9086
+ "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// \"Directory\" \u2014 a listings directory (categorized listings people can submit),\n// declared end-to-end in ONE typed file. A BLUEPRINT composing shipped blocks \u2014\n// \u2022 cms \u2192 categories \u2192 listings (resolved by relation)\n// \u2022 auth \u2192 accounts, so a submitter is a verified end-user\n// `listings.submitted_by` is the end-user OWNER field: in end-user mode a\n// submitter can only edit their OWN listing (a no-op for server callers). This\n// is one declarative flag, not a per-user policy. Everything here is DATA the\n// 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 draftPublish: true, // submissions land as drafts; an admin publishes them\n hooks: {\n // Every listing needs a name \u2014 a pure function of the row (Lane-A validate).\n listing_name: {\n collection: 'listings',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.name) > 0',\n message: 'a listing needs a name',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // submitters sign in as end-users\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 categories: {\n singular: 'category',\n // PUBLIC DELIVERY (cms.md \xA716): the category nav reads keyless too.\n public: true,\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n },\n },\n listings: {\n singular: 'listing',\n // End-user owner-scope (vxil.com/docs/guide/04-data-with-cms): a verified submitter may\n // only read/edit their OWN listing. Server callers are unaffected.\n ownerField: 'submitted_by',\n // PUBLIC DELIVERY (cms.md \xA716): the PUBLIC BROWSE VIEW \u2014 published listings\n // read with NO API key over GET /v1/cms/public/:tenantId/listings, and the\n // `submitted_by` owner field is STRIPPED from every served row (a visitor\n // never sees who submitted it). Owner-scoping (above) still governs the\n // authed edit lane \u2014 the two lanes are independent by design.\n public: true,\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n category: { type: 'relation', relationTo: 'categories', indexSlot: 's3' },\n submitted_by: { type: 'string', indexSlot: 's4' }, // the owner (end-user) id\n description: { type: 'text' },\n website: { type: 'string' }, // stored, not indexed\n location: { type: 'string' }, // stored, not indexed\n featured: { type: 'bool' },\n rating: { type: 'float', indexSlot: 'n1' },\n submitted_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'categories',\n items: [\n { name: 'Restaurants', slug: 'restaurants' },\n { name: 'Services', slug: 'services' },\n ],\n },\n ],\n },\n});\n",
9087
+ "readme": "# Listings Directory template\n\nA categorized listings directory (people submit entries) \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `categories` \u2014 name, unique slug. **`public: true`** \u2014 the category nav reads keyless.\n- `listings` \u2014 name, unique slug, `category` relation, `submitted_by` (the **owner field**), description,\n website, location, `featured`, `rating`, `submitted_at`. A Lane-A hook requires a name; `draftPublish` lets\n submissions land as drafts an admin publishes. **`public: true`** \u2014 the public browse view reads keyless,\n and `submitted_by` is stripped from every served row (a visitor never sees who submitted it).\n- `auth` (email/password) \u2014 so a submitter is a **verified end-user**.\n\n**Use it:**\n\n```bash\nvxil init --template directory\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **Owner-scoping is one declarative flag** \u2014 `ownerField: 'submitted_by'` (a real slot-bound string field):\n in end-user mode a submitter can only read/edit their **own** listing; server callers are unaffected\n (vxil.com/docs/guide/04-data-with-cms).\n- **Moderation is the lifecycle** \u2014 submissions land as drafts, an admin `publish` makes them public, and the\n public browse view is served over the keyless public-delivery lane (below).\n- **The name invariant is a Lane-A hook** \u2014 tenant-owned data you can edit after `init` (\xA77).\n\n```ts\n// the browse view with a server key: published listings in one category, best-rated first\nconst { items } = await vx.from('listings').query({\n filter: { category: categoryId, $status: 'published' }, sort: '-rating', limit: 25,\n});\n```\n\n**The public browse view \u2014 keyless** (vxil.com/docs/guide/04-data-with-cms). Because `listings`/`categories` are\n`public: true`, the public directory reads with **no API key** \u2014 the edge forces `status = 'published'`\n(pending submissions stay private until an admin publishes), edge-caches the page, and **strips the\n`submitted_by` owner field** from every row. Owner-scoping (\xA715) still governs the authed edit lane \u2014 the\ntwo lanes are independent.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public browse view \u2014 NO api key, submitted_by stripped\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'listings', { filter: { 'category.slug': 'restaurants' }, sort: '-rating' });\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (owner-scoping \xB7 **public delivery**) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/06-feature-catalog (auth) \xB7 `examples/feedback-board/` (a voting board with a Lane-A hook + an HTTP function) \xB7\n`templates/docs-site/` (a pure public-content site) \xB7 `templates/catalog/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
8952
9088
  "functions": {}
8953
9089
  },
8954
9090
  {
@@ -8966,7 +9102,7 @@ export default defineConfig({
8966
9102
  "hasFunctions": false,
8967
9103
  "byoKeys": [],
8968
9104
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \"Tasks\" \u2014 the minimal starter: one cms collection + notifications, declared\n// end-to-end in one typed file. The default `vxil init` scaffold. Everything\n// here is DATA you own and edit.\nexport default defineConfig({\n env: 'staging',\n\n // (a) per-feature config \u2014 the same TypeBox manifests, validated server-side.\n features: {\n cms: { draftPublish: true },\n notifications: { provider: 'mock', fromEmail: 'noreply@example.app' },\n },\n\n // (b) CMS schema-as-code \u2014 collections + fields (reconciled by `vxil push`).\n cms: {\n collections: {\n tasks: {\n singular: 'task',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n done: { type: 'bool' },\n due: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n // (c) functions \u2014 tenant code (\xA77.3, paid/opt-in). Uncomment to deploy.\n // functions: {\n // hello: { entry: './functions/hello.ts', trigger: { kind: 'http' } },\n // },\n\n // (d) seed \u2014 one row `vxil seed` applies so there's something to query.\n seed: {\n cms: [{ collection: 'tasks', items: [{ title: 'Try vxil push', done: false }] }],\n },\n});\n",
8969
- "readme": "# Tasks template (starter)\n\nThe minimal starter and the default `vxil init` scaffold \u2014 one `cms` collection (`tasks`) plus `notifications`.\n\n```bash\nvxil init # scaffolds this template by default\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The three-block config anatomy** \u2014 `features` (per-feature config, validated server-side),\n `cms.collections` (schema-as-code, reconciled by `vxil push`), and the commented-out `functions` block you\n uncomment when you need tenant code.\n- **Index slots are the query budget** \u2014 `title` (`s1`) and `due` (`t1`) are slot-bound so they filter/sort by\n range; `done` is a plain `bool` (equality-only, no slot) (`docs/features/cms.md` \xA73).\n- **`draftPublish: true`** \u2014 every item carries the draft\u2192published lifecycle, filtered with the reserved\n `$status` key.\n\n```ts\nconst { item_id } = await vx.from('tasks').create(\n { title: 'Ship the release', due: new Date(Date.now() + 86400e3).toISOString() },\n { status: 'published' },\n);\n\n// due before this time tomorrow, soonest first (slot-indexed range filter + sort)\nconst { items } = await vx.from('tasks').query({\n filter: { due: { $lt: new Date(Date.now() + 86400e3).toISOString() } }, sort: 'due', limit: 20,\n});\n```\n\n**Go deeper:** `docs/guide/05-typed-sdk-and-cli.md` (the `vx.from` handle) \xB7 `docs/features/cms.md` \xA73 \xB7\n`templates/notes/` (even smaller) \xB7 `templates/blog/` (relations + hooks).\n\nOwn the shape \u2014 add fields, add collections, uncomment the starter function. Nothing is locked.\n",
9105
+ "readme": "# Tasks template (starter)\n\nThe minimal starter and the default `vxil init` scaffold \u2014 one `cms` collection (`tasks`) plus `notifications`.\n\n```bash\nvxil init # scaffolds this template by default\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The three-block config anatomy** \u2014 `features` (per-feature config, validated server-side),\n `cms.collections` (schema-as-code, reconciled by `vxil push`), and the commented-out `functions` block you\n uncomment when you need tenant code.\n- **Index slots are the query budget** \u2014 `title` (`s1`) and `due` (`t1`) are slot-bound so they filter/sort by\n range; `done` is a plain `bool` (equality-only, no slot) (vxil.com/docs/guide/04-data-with-cms).\n- **`draftPublish: true`** \u2014 every item carries the draft\u2192published lifecycle, filtered with the reserved\n `$status` key.\n\n```ts\nconst { item_id } = await vx.from('tasks').create(\n { title: 'Ship the release', due: new Date(Date.now() + 86400e3).toISOString() },\n { status: 'published' },\n);\n\n// due before this time tomorrow, soonest first (slot-indexed range filter + sort)\nconst { items } = await vx.from('tasks').query({\n filter: { due: { $lt: new Date(Date.now() + 86400e3).toISOString() } }, sort: 'due', limit: 20,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/05-typed-sdk-and-cli (the `vx.from` handle) \xB7 vxil.com/docs/guide/04-data-with-cms (query DSL) \xB7\n`templates/notes/` (even smaller) \xB7 `templates/blog/` (relations + hooks).\n\nOwn the shape \u2014 add fields, add collections, uncomment the starter function. Nothing is locked.\n",
8970
9106
  "functions": {}
8971
9107
  },
8972
9108
  {
@@ -8983,14 +9119,14 @@ export default defineConfig({
8983
9119
  "hasFunctions": false,
8984
9120
  "byoKeys": [],
8985
9121
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \"Notes\" \u2014 a single-collection notes app (no draft/publish), declared end-to-end\n// in one typed file. A minimal starter. Everything here is DATA you own and edit.\nexport default defineConfig({\n env: 'staging',\n features: { cms: { draftPublish: false } },\n cms: {\n collections: {\n notes: {\n singular: 'note',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n body: { type: 'text' },\n pinned: { type: 'bool' },\n },\n },\n },\n },\n // One row of seed data `vxil seed` applies so there's something to query.\n seed: {\n cms: [{ collection: 'notes', items: [{ title: 'Welcome', body: 'This backend was declared in one typed file.', pinned: true }] }],\n },\n});\n",
8986
- "readme": "# Notes template (starter)\n\nThe simplest possible backend \u2014 one `cms` collection (`notes`), no draft/publish.\n\n```bash\nvxil init --template notes\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **How small a backend can be** \u2014 one feature, one collection, three fields; every config leaf omitted here\n falls back to a validated server-side default.\n- **`draftPublish: false`** \u2014 no editorial lifecycle: writes are live immediately (compare `templates/blog`).\n- **Slot only what you query** \u2014 `title` (`s1`) is slot-bound for search and sort; `body` (`text`) and `pinned`\n (`bool`) take no slot (`text`/`bool`/`json` aren't slottable) \u2014 equality on any field is still index-served\n (`docs/features/cms.md` \xA73).\n\n```ts\nawait vx.from('notes').create({ title: 'Meeting notes', body: 'Decisions\u2026', pinned: true });\n\n// slotted string fields take $contains (index-served substring search)\nconst { items } = await vx.from('notes').query({\n filter: { title: { $contains: 'meeting' } }, limit: 20,\n});\n```\n\n**Go deeper:** `docs/features/cms.md` \xA73 (query DSL) \xB7 `docs/guide/05-typed-sdk-and-cli.md` \xB7\n`templates/tasks/` (the default scaffold) \xB7 `templates/research-library/` (notes grown up).\n\nOwn the shape \u2014 nothing is locked.\n",
9122
+ "readme": "# Notes template (starter)\n\nThe simplest possible backend \u2014 one `cms` collection (`notes`), no draft/publish.\n\n```bash\nvxil init --template notes\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **How small a backend can be** \u2014 one feature, one collection, three fields; every config leaf omitted here\n falls back to a validated server-side default.\n- **`draftPublish: false`** \u2014 no editorial lifecycle: writes are live immediately (compare `templates/blog`).\n- **Slot only what you query** \u2014 `title` (`s1`) is slot-bound for search and sort; `body` (`text`) and `pinned`\n (`bool`) take no slot (`text`/`bool`/`json` aren't slottable) \u2014 equality on any field is still index-served\n (vxil.com/docs/guide/04-data-with-cms).\n\n```ts\nawait vx.from('notes').create({ title: 'Meeting notes', body: 'Decisions\u2026', pinned: true });\n\n// slotted string fields take $contains (index-served substring search)\nconst { items } = await vx.from('notes').query({\n filter: { title: { $contains: 'meeting' } }, limit: 20,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL) \xB7 vxil.com/docs/guide/05-typed-sdk-and-cli \xB7\n`templates/tasks/` (the default scaffold) \xB7 `templates/research-library/` (notes grown up).\n\nOwn the shape \u2014 nothing is locked.\n",
8987
9123
  "functions": {}
8988
9124
  }
8989
9125
  ];
8990
9126
 
8991
9127
  // src/migrate/adapters/csv-json.ts
8992
9128
  import { readFileSync as readFileSync6, readdirSync, existsSync as existsSync6, statSync } from "node:fs";
8993
- import { basename as basename3, extname, join as join2, resolve as resolve6 } from "node:path";
9129
+ import { basename as basename3, extname, join as join3, resolve as resolve6 } from "node:path";
8994
9130
  function parseCsv(text) {
8995
9131
  const src = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
8996
9132
  const rows = [];
@@ -9091,7 +9227,7 @@ function walkFiles(root, rel = "") {
9091
9227
  const entries = readdirSync(root, { withFileTypes: true }).sort((a, b2) => a.name.localeCompare(b2.name));
9092
9228
  for (const e of entries) {
9093
9229
  const r = rel === "" ? e.name : `${rel}/${e.name}`;
9094
- if (e.isDirectory()) out.push(...walkFiles(join2(root, e.name), r));
9230
+ if (e.isDirectory()) out.push(...walkFiles(join3(root, e.name), r));
9095
9231
  else out.push(r);
9096
9232
  }
9097
9233
  return out;
@@ -9110,7 +9246,7 @@ var CsvJsonAdapter = class {
9110
9246
  if (this.cache) return this.cache;
9111
9247
  if (!existsSync6(this.dir)) throw new Error(`csv-json: directory not found: ${this.dir}`);
9112
9248
  let overrides = {};
9113
- const schemaFile = join2(this.dir, "_schema.json");
9249
+ const schemaFile = join3(this.dir, "_schema.json");
9114
9250
  if (existsSync6(schemaFile)) {
9115
9251
  overrides = JSON.parse(readFileSync6(schemaFile, "utf8"));
9116
9252
  }
@@ -9128,7 +9264,7 @@ var CsvJsonAdapter = class {
9128
9264
  warnings.push(`csv-json: duplicate table '${tableName}' (both .csv and .json?) \u2014 first file wins`);
9129
9265
  continue;
9130
9266
  }
9131
- const t = file.endsWith(".csv") ? this.loadCsv(join2(this.dir, file), schemaName, tableName, overrides, warnings) : this.loadJson(join2(this.dir, file), schemaName, tableName, overrides, warnings);
9267
+ const t = file.endsWith(".csv") ? this.loadCsv(join3(this.dir, file), schemaName, tableName, overrides, warnings) : this.loadJson(join3(this.dir, file), schemaName, tableName, overrides, warnings);
9132
9268
  if (t) tables.set(tableName, t);
9133
9269
  }
9134
9270
  if (tables.size === 0) warnings.push(`csv-json: no .csv/.json table files found in the directory`);
@@ -9164,9 +9300,9 @@ var CsvJsonAdapter = class {
9164
9300
  enums: [],
9165
9301
  warnings
9166
9302
  };
9167
- const objectsRoot = join2(this.dir, OBJECTS_DIR);
9303
+ const objectsRoot = join3(this.dir, OBJECTS_DIR);
9168
9304
  if (existsSync6(objectsRoot)) {
9169
- const buckets = readdirSync(objectsRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => ({ name: e.name, objectCount: walkFiles(join2(objectsRoot, e.name)).length })).sort((a, b2) => a.name.localeCompare(b2.name));
9305
+ const buckets = readdirSync(objectsRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => ({ name: e.name, objectCount: walkFiles(join3(objectsRoot, e.name)).length })).sort((a, b2) => a.name.localeCompare(b2.name));
9170
9306
  if (buckets.length > 0) schema.buckets = buckets;
9171
9307
  }
9172
9308
  if (overrides.authTable !== void 0) {
@@ -9299,12 +9435,12 @@ var CsvJsonAdapter = class {
9299
9435
  /** storage objects from the `_objects/<bucket>/**` manifest (R14). Bytes are
9300
9436
  * read LAZILY per object via stream() — a dry-run never opens them. */
9301
9437
  async *readObjects(bucket) {
9302
- const root = join2(this.dir, OBJECTS_DIR, bucket);
9438
+ const root = join3(this.dir, OBJECTS_DIR, bucket);
9303
9439
  if (!existsSync6(root)) {
9304
9440
  throw new Error(`csv-json: unknown bucket '${bucket}' \u2014 no ${OBJECTS_DIR}/${bucket}/ directory in the export`);
9305
9441
  }
9306
9442
  for (const rel of walkFiles(root)) {
9307
- const abs = join2(root, rel);
9443
+ const abs = join3(root, rel);
9308
9444
  const ext = rel.slice(rel.lastIndexOf(".") + 1).toLowerCase();
9309
9445
  yield {
9310
9446
  path: rel,
@@ -10787,7 +10923,8 @@ function initProject() {
10787
10923
  if (pkgRes.action === "created" || pkgRes.action === "added") {
10788
10924
  console.log(" package.json # declares @vxil/config (the config import)");
10789
10925
  }
10790
- console.log("\nnext: `npm install` (installs @vxil/config for vxil.config.ts \u2014 or pnpm/yarn), then `vxil quickstart` (new tenant) or `vxil link <slug>` (existing), then `vxil push`.");
10926
+ console.log("\nnext: `vxil quickstart` (new tenant) or `vxil link <slug>` (existing), then `vxil push`.");
10927
+ console.log(" (`npm install` is optional \u2014 it gives your editor the @vxil/config types; the CLI runs vxil.config.ts without it.)");
10791
10928
  }
10792
10929
  function listTemplates() {
10793
10930
  console.log("vxil templates \u2014 the Blueprint Gallery. Scaffold one with `vxil init --template <id>`.\n");
@@ -10812,15 +10949,17 @@ try {
10812
10949
  case "quickstart": {
10813
10950
  const base = process.env.VXIL_BASE_URL ?? DEFAULT_BASE;
10814
10951
  const dash = dashboardBase(base);
10952
+ const featuresFlag = flag("features");
10953
+ const hasConfig = CONFIG_FILENAMES.some((n) => existsSync7(resolve7(process.cwd(), n)));
10954
+ const features = quickstartFeatures(featuresFlag, featuresFlag === void 0 && hasConfig ? await loadVxilConfig() : null);
10815
10955
  const email = flag("email") ?? await prompt("email: ");
10816
10956
  const password = flag("password") ?? await prompt("password (>=10 chars): ", { hidden: true });
10817
- const featuresArg = flag("features") ?? "cms,notifications";
10818
10957
  const appName = flag("name") ?? "vxil-app";
10819
10958
  const body = {
10820
10959
  email,
10821
10960
  password,
10822
10961
  app_name: appName,
10823
- features: featuresArg.split(",").map((s) => s.trim()).filter(Boolean),
10962
+ features,
10824
10963
  // --dev mints an ephemeral preview tenant the server-side TTL reaper
10825
10964
  // tears down after --ttl hours (default 72) — the per-PR CI backend.
10826
10965
  ...hasFlag("dev") ? { kind: "dev", ttl_hours: Number(flag("ttl") ?? 72) } : {},
@@ -10870,6 +11009,7 @@ try {
10870
11009
  })();
10871
11010
  console.log(`
10872
11011
  \u2728 your backend is live`);
11012
+ console.log(` tenant: ${d.tenant_id}`);
10873
11013
  console.log(` dashboard: ${spaOrigin}/dashboard`);
10874
11014
  console.log(` api: ${d.edge_url ?? base}`);
10875
11015
  console.log(` types: ./vxil.types.ts (generated \u2014 re-run \`vxil gen\` after a schema change)`);