@vxil/cli 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +16 -6
- package/dist/vxil.js +37 -19
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -120,15 +120,25 @@ export type FunctionTrigger = {
|
|
|
120
120
|
source: string;
|
|
121
121
|
}
|
|
122
122
|
/** `webhook`: the function is woken by a webhook_subscription fan-out whose
|
|
123
|
-
* target_url is `<edge>/v1/internal/fn/trigger/<name>?tenant=<id>`.
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* function
|
|
127
|
-
*
|
|
123
|
+
* target_url is `<edge>/v1/internal/fn/trigger/<name>?tenant=<id>`. That
|
|
124
|
+
* subscription is AUTO-WIRED on deploy (2026-09-19): the platform reconciles
|
|
125
|
+
* one per function declaring this binding, with `event_prefixes` = the
|
|
126
|
+
* function's `source` values — a hand-made one equal in target + prefixes is
|
|
127
|
+
* adopted. `source` is an event-name PREFIX (e.g. 'payments.') or a full event
|
|
128
|
+
* name — lowercase dotted segments, ≤64 chars, validated at deploy — and is
|
|
129
|
+
* also enforced at delivery: a non-matching event is ACK-200 skipped.
|
|
130
|
+
* Declaring the binding is what opts the function into event delivery; a
|
|
131
|
+
* receiver that declares only `cron` keeps the older cron+{} wake. */
|
|
128
132
|
| {
|
|
129
133
|
kind: 'webhook';
|
|
130
134
|
source: string;
|
|
131
|
-
}
|
|
135
|
+
}
|
|
136
|
+
/** `cmsHook`: fires after a CMS write in `collection`. The platform filters
|
|
137
|
+
* deliveries on the binding (2026-09-19): `beforeCreate` → created only,
|
|
138
|
+
* `beforeUpdate` → updated only, `beforeWrite` → created|updated; a write to
|
|
139
|
+
* another collection never wakes the function. Deleted/bulk events are not
|
|
140
|
+
* delivered to a cmsHook binding. */
|
|
141
|
+
| {
|
|
132
142
|
kind: 'cmsHook';
|
|
133
143
|
collection: string;
|
|
134
144
|
event: 'beforeCreate' | 'beforeUpdate' | 'beforeWrite';
|
package/dist/vxil.js
CHANGED
|
@@ -393,6 +393,14 @@ var POLICY_LIST_CAP = 100;
|
|
|
393
393
|
var CAMPAIGN_LIST_CAP = 200;
|
|
394
394
|
var LEGACY_POLICY_ALGORITHM = "sliding_window";
|
|
395
395
|
var DEFAULT_SUBSCRIPTION_STATE = "active";
|
|
396
|
+
var FN_TRIGGER_TARGET_MARKERS = [
|
|
397
|
+
"/v1/internal/fn/cms-hook/",
|
|
398
|
+
"/v1/internal/fn/auth-hook/",
|
|
399
|
+
"/v1/internal/fn/trigger/"
|
|
400
|
+
];
|
|
401
|
+
function isFnTriggerSubscription(targetUrl) {
|
|
402
|
+
return FN_TRIGGER_TARGET_MARKERS.some((m) => targetUrl.includes(m));
|
|
403
|
+
}
|
|
396
404
|
function str(v) {
|
|
397
405
|
return typeof v === "string" ? v : void 0;
|
|
398
406
|
}
|
|
@@ -447,6 +455,7 @@ async function fetchApiState(api) {
|
|
|
447
455
|
const s = raw;
|
|
448
456
|
const url = str(s.target_url);
|
|
449
457
|
if (!url) continue;
|
|
458
|
+
if (isFnTriggerSubscription(url)) continue;
|
|
450
459
|
snap.subscriptions.push({
|
|
451
460
|
target_url: url,
|
|
452
461
|
event_prefixes: strList(s.event_prefixes),
|
|
@@ -715,7 +724,7 @@ async function planFunctions({ api, functions, cwd, apply }) {
|
|
|
715
724
|
`functions: cannot read ${read.unavailable.route} (${read.unavailable.code ?? read.unavailable.status}${read.unavailable.message ? ` \u2014 ${read.unavailable.message}` : ""}) \u2014 refusing to deploy a plan computed against an unread target`
|
|
716
725
|
);
|
|
717
726
|
}
|
|
718
|
-
return { changes: [], applied: 0, cmsHookSubscriptions: null, remoteUnavailable: read.unavailable };
|
|
727
|
+
return { changes: [], applied: 0, cmsHookSubscriptions: null, webhookSubscriptions: null, remoteUnavailable: read.unavailable };
|
|
719
728
|
}
|
|
720
729
|
const remote = read.map;
|
|
721
730
|
const results = await mapPool(Object.entries(functions), 1, async ([name, def]) => {
|
|
@@ -731,7 +740,7 @@ async function planFunctions({ api, functions, cwd, apply }) {
|
|
|
731
740
|
const sha = sourceSha12(source);
|
|
732
741
|
const label = `${name} (${Math.round(bytes / 100) / 10} KB) [${triggerKind}${def.scopes?.length ? " \xB7 " + def.scopes.join(",") : ""}${secrets.length ? " \xB7 secrets:" + secrets.map((s) => s.slice("secret:".length)).join(",") : ""}${limits?.cpuMs !== void 0 ? " \xB7 cpu:" + limits.cpuMs + "ms" : ""}${limits?.timeoutMs !== void 0 ? " \xB7 egress:" + limits.timeoutMs + "ms" : ""}]`;
|
|
733
742
|
if (remoteFn !== void 0 && remoteFn.scriptRef.endsWith(`-${sha}`) && sameSecretSet(remoteFn.secrets, secrets) && sameStringSet(clampFunctionScopes(def.scopes), remoteFn.scopes) && sameStringSet(def.egressAllow ?? [], remoteFn.egressAllow) && stableStringify(remoteFn.bindings) === stableStringify(bindings) && stableStringify(remoteFn.signature) === stableStringify(def.signature ?? null) && stableStringify(remoteFn.limits ?? null) === stableStringify(limits ?? null)) {
|
|
734
|
-
return { change: { name, kind: "unchanged", detail: label }, applied: 0, cmsHooks: null };
|
|
743
|
+
return { change: { name, kind: "unchanged", detail: label }, applied: 0, cmsHooks: null, webhooks: null };
|
|
735
744
|
}
|
|
736
745
|
const change = { name, kind: remoteFn === void 0 ? "deploy-new" : "deploy-update", detail: label };
|
|
737
746
|
if (apply) {
|
|
@@ -746,18 +755,23 @@ async function planFunctions({ api, functions, cwd, apply }) {
|
|
|
746
755
|
const e = res.body.error ?? {};
|
|
747
756
|
throw new Error(`functions: deploy ${name} failed: ${e.code ?? res.status} ${e.message ?? ""} ${e.hint ?? ""}`);
|
|
748
757
|
}
|
|
749
|
-
const
|
|
750
|
-
|
|
758
|
+
const data2 = res.body.data;
|
|
759
|
+
const cmsHooks = data2?.cms_hook_subscriptions ?? null;
|
|
760
|
+
const webhooks = data2?.webhook_subscriptions ?? null;
|
|
761
|
+
return { change, applied: 1, cmsHooks, webhooks };
|
|
751
762
|
}
|
|
752
|
-
return { change, applied: 0, cmsHooks: null };
|
|
763
|
+
return { change, applied: 0, cmsHooks: null, webhooks: null };
|
|
753
764
|
});
|
|
754
765
|
const changes = results.map((r) => r.change);
|
|
755
766
|
const applied = results.reduce((s, r) => s + r.applied, 0);
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
767
|
+
const sum = (pick) => results.reduce((acc, r) => {
|
|
768
|
+
const v = pick(r);
|
|
769
|
+
if (!v) return acc;
|
|
770
|
+
return { created: (acc?.created ?? 0) + v.created, deleted: (acc?.deleted ?? 0) + v.deleted };
|
|
759
771
|
}, null);
|
|
760
|
-
|
|
772
|
+
const cmsHookSubscriptions = sum((r) => r.cmsHooks);
|
|
773
|
+
const webhookSubscriptions = sum((r) => r.webhooks);
|
|
774
|
+
return { changes, applied, cmsHookSubscriptions, webhookSubscriptions };
|
|
761
775
|
}
|
|
762
776
|
function formatFnChanges(changes) {
|
|
763
777
|
const actionable = changes.filter((c) => c.kind !== "unchanged");
|
|
@@ -6490,7 +6504,7 @@ function detectOwner(table, source, authTable, opts) {
|
|
|
6490
6504
|
for (const p of source.rlsPolicies ?? []) {
|
|
6491
6505
|
if (lastSeg(p.table) !== table.name) continue;
|
|
6492
6506
|
const m = /auth\.uid\(\)\s*=\s*(?:[\w"]+\.)?"?([A-Za-z_]\w*)"?/.exec(p.definition) ?? /(?:[\w"]+\.)?"?([A-Za-z_]\w*)"?\s*=\s*auth\.uid\(\)/.exec(p.definition);
|
|
6493
|
-
if (m && cols.has(m[1])) return { column: m[1], via: `
|
|
6507
|
+
if (m && cols.has(m[1])) return { column: m[1], via: `row-level access rule${p.name ? ` '${p.name}'` : ""}` };
|
|
6494
6508
|
}
|
|
6495
6509
|
for (const fk of source.foreignKeys) {
|
|
6496
6510
|
if (fk.childTable !== table.name || fk.childColumns.length !== 1) continue;
|
|
@@ -6829,7 +6843,7 @@ function inferMapping(source, opts = {}) {
|
|
|
6829
6843
|
}
|
|
6830
6844
|
if ((source.rlsPolicies?.length ?? 0) > 0) {
|
|
6831
6845
|
residuals.push(
|
|
6832
|
-
`R15: ${source.rlsPolicies.length}
|
|
6846
|
+
`R15: ${source.rlsPolicies.length} row-level access rules are NOT recreated \u2014 they become per-collection ownerField + an end_user_required key + the X-Vxil-End-User principal (R6/R7); raw policies are listed in the plan for review`
|
|
6833
6847
|
);
|
|
6834
6848
|
}
|
|
6835
6849
|
if ((source.buckets?.length ?? 0) > 0) {
|
|
@@ -7874,7 +7888,7 @@ var STANDING_CAVEATS = [
|
|
|
7874
7888
|
"Passwords: never imported (auth is scrypt-locked; the foreign-hash import is signal-gated, not built) \u2014 users re-auth via magic-link/OTP/social/reset on first sign-in.",
|
|
7875
7889
|
"Push device tokens: no vxil landing (no push channel) \u2014 clients re-enroll post-cutover.",
|
|
7876
7890
|
"Subscriptions / entitlements / tier: NEVER seeded (provider-derived truth) \u2014 rebuild from your live provider webhooks after cutover.",
|
|
7877
|
-
"DB triggers / RPCs / pg_cron /
|
|
7891
|
+
"DB triggers / RPCs / pg_cron / row-level access rules: not auto-migrated \u2014 hand-wire as vxil functions (cms-hook/cron/http triggers; cms-hooks are async post-commit, never in-transaction) + per-collection ownerField.",
|
|
7878
7892
|
"File objects: re-keyed (new object_id; source paths/URLs not preserved) \u2014 mint downloadUrl at read time, never persist it."
|
|
7879
7893
|
];
|
|
7880
7894
|
function emitResidualsMd(plan, sourceLabel) {
|
|
@@ -8748,7 +8762,7 @@ export default defineConfig({
|
|
|
8748
8762
|
"readme": '# AI Journal template\n\nAn AI-powered private journal \u2014 declared end-to-end in one typed `vxil.config.ts`. Every saved entry is\nenriched by a function (one-sentence summary + one-word mood via the `ai` feature) and indexed for retrieval,\nso you can literally *ask your journal* and get grounded, cited answers back.\n\n**What it provisions:**\n- `entries` \u2014 title, body, AI-derived `mood`/`summary`, `written_at`, tags, and `user_id` as the **owner field**\n (a verified end-user only sees their own journal). Lane-A hooks require a title and stamp `written_at`.\n- Features: `cms` + `ai` + `rag` + `vector-search` (rag\'s retrieval leg) + `notifications` + `functions`.\n- Functions: `on-entry-written` (cmsHook: enrich + ingest), `ask-journal` (http: grounded Q&A),\n `weekly-digest` (cron: Monday digest per writer).\n\n**Apply it:**\n\n```bash\nvxil init --template ai-journal\nvxil quickstart --invite <code> # only when the email is new (or `vxil link` an existing tenant)\nvxil push\nvxil gen\n# one-time: create the retrieval index (dimensions/embedder come from config defaults)\ncurl -X POST https://api.vxil.com/v1/search/collections \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"collection":"journal"}\'\n```\n\n**What to learn from this:**\n1. **AI enrichment on write** \u2014 a `cmsHook` function re-fetches the entry by `item_id` (never trusts inline\n fields), calls `POST /v1/ai/generate` (raw-prompt mode), PATCHes `summary`/`mood` back, and latches on\n `summary` so its own write-back never re-enriches.\n2. **Retrieval-augmented "ask your journal"** \u2014 `POST /v1/rag/answer` retrieves top-k from the `journal`\n index and returns the answer *with citations* (`doc_id` = the entry\'s `item_id`); the prompt stays yours.\n3. **BYO AI key via encrypted secrets** \u2014 config carries only the reference (`providers.openaiKeyRef`);\n `vxil secrets set ai/openai_key` stores the value envelope-encrypted, then flip `defaultProvider`/`model`.\n Until then the deterministic `mock` provider (and mock embedder) run the whole loop keyless.\n\n```bash\ncurl -X POST https://api.vxil.com/v1/fn/ask-journal \\\n -H "Authorization: Bearer $VXIL_KEY" -H "Content-Type: application/json" \\\n -d \'{"question":"what made me happy this month?"}\'\n```\n\n**Go deeper:** vxil.com/docs/guide/06-feature-catalog (ai, rag) \xB7 vxil.com/docs/guide/08-running-your-code-functions \xB7\nvxil.com/docs/guide/07-validation-and-hooks \xB7 vxil.com/docs/guide/04-data-with-cms (owner-scope) \xB7 `examples/ecommerce/` (a bigger functions saga).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
|
|
8749
8763
|
"functions": {
|
|
8750
8764
|
"ask-journal.ts": "// ask-journal.ts \u2014 \"ASK YOUR JOURNAL\" (a vxil function, \xA77.3).\n//\n// Trigger: http \u2014 POST /v1/fn/ask-journal { question, user_id? }. Runs ONE\n// retrieval-augmented call: POST /v1/rag/answer over the `journal` index the\n// on-entry-written function keeps fed. rag retrieves top-k chunks from\n// vector-search, grounds the tenant-owned prompt, generates via the ai feature,\n// and returns the answer WITH citations pointing at the exact entries used \u2014\n// this function is a thin, scoped wrapper (rag:write only).\n//\n// In end-user mode the verified principal is propagated automatically into the\n// scoped token, so retrieval is owner-scoped; in server mode an optional\n// `user_id` rides along for per-user metering.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n // http-trigger: the caller's JSON body lands under `payload`.\n payload?: { question?: string; user_id?: string };\n}\ninterface Citation { chunk_id?: string; doc_id?: string; score?: number }\ninterface AnswerRes { data?: { answer?: string; citations?: Citation[]; usage?: Record<string, unknown> } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const rag = env.scoped_jwts?.rag;\n if (!rag) return json({ error: 'missing rag scope' }, 403);\n\n const question = String(env.payload?.question ?? '').trim();\n if (!question) return json({ error: 'question required', example: { question: 'what made me happy last month?' } }, 400);\n\n const res = await fetch(`${base}/v1/rag/answer`, {\n method: 'POST',\n headers: { authorization: `Bearer ${rag}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n query: question.slice(0, 2000),\n collection: 'journal', // = rag config defaultCollection; explicit for clarity\n ...(env.payload?.user_id ? { user_id: env.payload.user_id } : {}),\n }),\n });\n if (!res.ok) {\n // A missing index is NOT a 404 here: rag's retrieve leg wraps a\n // vector-search failure as 502 retrieval_failed and attaches the\n // downstream error under error.upstream (only a 501 passes through),\n // so detect collection_not_found in the BODY, not the status. The\n // index is a one-time setup (see the template README).\n const errBody = (await res.json().catch(() => null)) as\n { error?: { code?: string; upstream?: { code?: string } } } | null;\n const code = errBody?.error?.upstream?.code ?? errBody?.error?.code;\n if (res.status === 404 || code === 'collection_not_found') {\n return json({ error: 'journal index not found', hint: 'POST /v1/search/collections {\"collection\":\"journal\"} once, then write an entry' }, 404);\n }\n return json({ error: 'answer_failed', status: res.status }, 502);\n }\n\n const body = (await res.json()) as AnswerRes;\n return json({\n answer: body.data?.answer ?? '',\n // provenance: which entries grounded the answer (doc_id = the entry's item_id)\n sources: (body.data?.citations ?? []).map((c) => ({ entry_id: c.doc_id, score: c.score })),\n }, 200);\n },\n};\n\n// \u2500\u2500 tiny helper \u2500\u2500\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
|
|
8751
|
-
"on-entry-written.ts": "// on-entry-written.ts \u2014 AI ENRICHMENT ON WRITE (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `entries`. The hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// entry by id (through the edge, tenant-scoped), then:\n// 1. asks the ai feature (POST /v1/ai/generate, raw-prompt mode) for a\n// ONE-sentence summary and a ONE-word mood,\n// 2. PATCHes them back onto the entry (merge-patch; the summary-present LATCH\n// keeps our own write-back from re-enriching \u2014 clear `summary` to redo),\n// 3. ingests title+body into the rag retrieval index (POST /v1/rag/ingest/\n// journal \u2014 the vector-search passthrough) so ask-journal can ground on it.\n// At-least-once delivery is safe to redeliver: the summary latch skips a\n// re-enrich, the PATCH is idempotent by content, and the ingest converges \u2014\n// vector-search upserts by doc_id, so re-ingesting the same entry re-indexes\n// in place rather than duplicating.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\ninterface EntryData { title?: string; body?: string; summary?: string; mood?: string; written_at?: string; user_id?: string }\ninterface Item { data?: { data?: EntryData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const rag = env.scoped_jwts?.rag;\n const itemId = env.payload?.item_id;\n if (env.payload?.collection !== 'entries' || !cms || !ai || !rag || !itemId) {\n return Response.json({ skipped: true });\n }\n\n // Re-fetch the entry (the payload carries only the id \u2014 never trust inline fields).\n const res = await fetch(`${base}/v1/cms/items/entries/${itemId}`, { headers: H(cms) });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const entry = ((await res.json()) as Item).data?.data ?? {};\n if (entry.summary) return Response.json({ skipped: true, reason: 'already enriched' });\n if (!entry.body) return Response.json({ skipped: true, reason: 'no body yet' });\n\n // 1. AI enrichment \u2014 two small raw-prompt generations ({ data: { text } }).\n const text = entry.body.slice(0, 6000);\n const summary = clip(await generate(base, ai,\n `Summarize this journal entry in exactly one sentence, first person:\\n\\n${text}`, 80, entry.user_id), 400);\n const moodRaw = await generate(base, ai,\n `Answer with ONE lowercase word (e.g. joyful, anxious, calm, tired) naming the dominant mood of this journal entry:\\n\\n${text}`, 8, entry.user_id);\n const mood = (moodRaw.trim().split(/\\s+/)[0] ?? '').toLowerCase().replace(/[^a-z-]/g, '').slice(0, 24);\n if (!summary) return Response.json({ skipped: true, reason: 'ai unavailable' });\n\n // 2. PATCH the derived fields back (merge-patch keys; bumps `version`).\n const patch = await fetch(`${base}/v1/cms/items/entries/${itemId}`, {\n method: 'PATCH',\n headers: H(cms),\n body: JSON.stringify({ data: { summary, ...(mood ? { mood } : {}) } }),\n });\n\n // 3. Ingest into the retrieval index (rag \u2192 vector-search passthrough, 202).\n // Idempotent by doc_id: vector-search UPSERTs on (collection, doc_id), so a\n // redelivered hook (or an edited entry) re-indexes in place.\n const ing = await fetch(`${base}/v1/rag/ingest/journal`, {\n method: 'POST',\n headers: H(rag),\n body: JSON.stringify({\n doc_id: itemId,\n ...(entry.user_id ? { user_id: entry.user_id } : {}),\n text: `${entry.title ?? ''}\\n\\n${entry.body}`,\n metadata: { ...(mood ? { mood } : {}), ...(entry.written_at ? { written_at: entry.written_at } : {}) },\n }),\n });\n return Response.json({\n enriched: patch.ok,\n mood,\n ingested: ing.ok,\n // the index is a one-time setup: POST /v1/search/collections {\"collection\":\"journal\"}\n ...(ing.status === 404 ? { hint: 'create the journal index first (see the template README)' } : {}),\n });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n/** One raw-prompt sync generation; '' on any failure (enrichment is best-effort). */\nasync function generate(base: string, jwt: string, prompt: string, maxTokens: number, userId?: string): Promise<string> {\n const r = await fetch(`${base}/v1/ai/generate`, {\n method: 'POST',\n headers: H(jwt),\n body: JSON.stringify({ prompt, max_tokens: maxTokens, ...(userId ? { user_id: userId } : {}) }),\n }).catch(() => null);\n if (!r || !r.ok) return '';\n return String(((await r.json()) as { data?: { text?: string } }).data?.text ?? '');\n}\nconst clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '\u2026' : s);\n",
|
|
8765
|
+
"on-entry-written.ts": "// on-entry-written.ts \u2014 AI ENRICHMENT ON WRITE (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `entries`. The hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// entry by id (through the edge, tenant-scoped), then:\n// 1. asks the ai feature (POST /v1/ai/generate, raw-prompt mode) for a\n// ONE-sentence summary and a ONE-word mood,\n// 2. PATCHes them back onto the entry (merge-patch; the summary-present LATCH\n// keeps our own write-back from re-enriching \u2014 clear `summary` to redo),\n// 3. ingests title+body into the rag retrieval index (POST /v1/rag/ingest/\n// journal \u2014 the vector-search passthrough) so ask-journal can ground on it.\n// At-least-once delivery is safe to redeliver: the summary latch skips a\n// re-enrich, the PATCH is idempotent by content, and the ingest converges \u2014\n// vector-search upserts by doc_id, so re-ingesting the same entry re-indexes\n// in place rather than duplicating.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\ninterface EntryData { title?: string; body?: string; summary?: string; mood?: string; written_at?: string; user_id?: string }\ninterface Item { data?: { data?: EntryData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const rag = env.scoped_jwts?.rag;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (functions.md \xA73); this guard stays as belt-and-braces.\n if (env.payload?.collection !== 'entries' || !cms || !ai || !rag || !itemId) {\n return Response.json({ skipped: true });\n }\n\n // Re-fetch the entry (the payload carries only the id \u2014 never trust inline fields).\n const res = await fetch(`${base}/v1/cms/items/entries/${itemId}`, { headers: H(cms) });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const entry = ((await res.json()) as Item).data?.data ?? {};\n if (entry.summary) return Response.json({ skipped: true, reason: 'already enriched' });\n if (!entry.body) return Response.json({ skipped: true, reason: 'no body yet' });\n\n // 1. AI enrichment \u2014 two small raw-prompt generations ({ data: { text } }).\n const text = entry.body.slice(0, 6000);\n const summary = clip(await generate(base, ai,\n `Summarize this journal entry in exactly one sentence, first person:\\n\\n${text}`, 80, entry.user_id), 400);\n const moodRaw = await generate(base, ai,\n `Answer with ONE lowercase word (e.g. joyful, anxious, calm, tired) naming the dominant mood of this journal entry:\\n\\n${text}`, 8, entry.user_id);\n const mood = (moodRaw.trim().split(/\\s+/)[0] ?? '').toLowerCase().replace(/[^a-z-]/g, '').slice(0, 24);\n if (!summary) return Response.json({ skipped: true, reason: 'ai unavailable' });\n\n // 2. PATCH the derived fields back (merge-patch keys; bumps `version`).\n const patch = await fetch(`${base}/v1/cms/items/entries/${itemId}`, {\n method: 'PATCH',\n headers: H(cms),\n body: JSON.stringify({ data: { summary, ...(mood ? { mood } : {}) } }),\n });\n\n // 3. Ingest into the retrieval index (rag \u2192 vector-search passthrough, 202).\n // Idempotent by doc_id: vector-search UPSERTs on (collection, doc_id), so a\n // redelivered hook (or an edited entry) re-indexes in place.\n const ing = await fetch(`${base}/v1/rag/ingest/journal`, {\n method: 'POST',\n headers: H(rag),\n body: JSON.stringify({\n doc_id: itemId,\n ...(entry.user_id ? { user_id: entry.user_id } : {}),\n text: `${entry.title ?? ''}\\n\\n${entry.body}`,\n metadata: { ...(mood ? { mood } : {}), ...(entry.written_at ? { written_at: entry.written_at } : {}) },\n }),\n });\n return Response.json({\n enriched: patch.ok,\n mood,\n ingested: ing.ok,\n // the index is a one-time setup: POST /v1/search/collections {\"collection\":\"journal\"}\n ...(ing.status === 404 ? { hint: 'create the journal index first (see the template README)' } : {}),\n });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n/** One raw-prompt sync generation; '' on any failure (enrichment is best-effort). */\nasync function generate(base: string, jwt: string, prompt: string, maxTokens: number, userId?: string): Promise<string> {\n const r = await fetch(`${base}/v1/ai/generate`, {\n method: 'POST',\n headers: H(jwt),\n body: JSON.stringify({ prompt, max_tokens: maxTokens, ...(userId ? { user_id: userId } : {}) }),\n }).catch(() => null);\n if (!r || !r.ok) return '';\n return String(((await r.json()) as { data?: { text?: string } }).data?.text ?? '');\n}\nconst clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '\u2026' : s);\n",
|
|
8752
8766
|
"weekly-digest.ts": "// weekly-digest.ts \u2014 THE WEEKLY DIGEST (a vxil function, \xA77.3).\n//\n// Trigger: cron ('0 8 * * 1' \u2014 Mondays 08:00 UTC, delivered via the jobs\n// schedule the control-plane reconciles per cron binding). Lists the last 7\n// days of entries (written_at rides the t1 index slot, so the $gte range +\n// sort=-written_at are index-served), groups them per writer, and sends each\n// writer ONE notifications digest ({ subject, paragraph } on the built-in\n// 'transactional' template).\n//\n// Delivery notes: notifications resolves user_id against your end users \u2014 a\n// writer with no email fails that ONE send (user_email_missing) and the loop\n// continues. The per-user Idempotency-Key (envelope key + user id) makes the\n// at-least-once cron redelivery never double-send.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n idempotency_key?: string;\n}\ninterface EntryData { title?: string; mood?: string; user_id?: string; written_at?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n if (!cms || !notif) return Response.json({ skipped: true, reason: 'missing cms/notifications scope' });\n\n // 1. the week's entries, newest first (t1-slotted range + sort).\n const since = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();\n const filter = encodeURIComponent(JSON.stringify({ written_at: { $gte: since } }));\n const res = await fetch(`${base}/v1/cms/items/entries?filter=${filter}&sort=-written_at&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ skipped: true, reason: `list ${res.status}` });\n const body = (await res.json()) as { data?: { items?: { id: string; data: EntryData }[] } };\n const items = body.data?.items ?? [];\n\n // 2. group per writer.\n const byUser = new Map<string, EntryData[]>();\n for (const it of items) {\n const uid = it.data.user_id;\n if (!uid) continue;\n const list = byUser.get(uid) ?? [];\n list.push(it.data);\n byUser.set(uid, list);\n }\n\n // 3. one digest send per writer (best-effort per user; the loop never aborts).\n let sent = 0;\n for (const [uid, entries] of byUser) {\n const lines = entries\n .slice(0, 10)\n .map((e) => `\u2022 ${e.title ?? 'Untitled'}${e.mood ? ` (${e.mood})` : ''}`)\n .join('\\n');\n const ok = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n 'idempotency-key': `${env.idempotency_key ?? 'weekly-digest'}:${uid}`,\n },\n body: JSON.stringify({\n user_id: uid,\n template: 'transactional',\n data: {\n subject: `Your journal week \u2014 ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`,\n paragraph: `You wrote ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'} this week:\\n${lines}`,\n },\n }),\n }).then((r) => r.ok).catch(() => false);\n if (ok) sent += 1;\n }\n\n return Response.json({ entries: items.length, writers: byUser.size, sent });\n },\n};\n"
|
|
8753
8767
|
}
|
|
8754
8768
|
},
|
|
@@ -8779,8 +8793,8 @@ export default defineConfig({
|
|
|
8779
8793
|
"readme": '# Agent Desk (ai)\n\nThe whole AI half of vxil on a deliberately small support desk \u2014 two collections, three functions,\nand one idea per feature. If you have been trying to work out where `ai`, `rag`, `vector-search`,\n`copilot` and `mcp` differ, this is the blueprint that answers it by making each one do exactly its\nown job.\n\n```bash\nvxil init --template agent-desk\nvxil quickstart # or `vxil link <slug>`\nprintf \'%s\' "$READ_KEY" | vxil secrets set functions/vxil_read_key\nvxil push # collections + the three functions\n```\n\nEverything runs on the deterministic **`mock`** model provider, so the walkthrough below is\nreproducible with no provider account and no spend. Swapping in a real model is one config line and\none secret \u2014 nothing else in this blueprint changes.\n\n`vxil_read_key` is a key **of this same backend** carrying only `features:read` and `webhooks:read`;\nthe capability probe uses it (dashboard \u2192 API keys \u2192 create, tick those two and nothing else).\n\n## One idea per feature\n\n| Feature | The one thing it does here | Why it is not one of the others |\n|---|---|---|\n| `ai` classify | pick exactly one label from a fixed set | a chat prompt can return a paragraph; a classifier cannot |\n| `ai` judge | score a draft as an integer on a fixed scale | the model that writes is not the authority on whether the writing is good |\n| `vector-search` | hold the knowledge index, synced from `kb` | retrieval, not generation \u2014 no prompt lives here |\n| `rag` | answer **only** from what was retrieved, with citations | the pipeline; the *prompt* is a template you own |\n| `copilot` | the in-app assistant: propose a write, a human confirms | it is a composition over the four above, not a fifth model |\n| `mcp` | the same backend, as tools, narrowed per key | an agent\'s *interface*, not an agent |\n| `functions` | the deterministic steps around the model calls | the parts that must not be creative |\n\n## Prompts are yours, not config\n\n`rag.defaultTemplate: \'support-answer\'` names a **prompt template**, which is a versioned row you\ncreate over the API \u2014 deliberately not a config leaf, because a prompt is the part of the product\nyou iterate on hourly. Create it before the first answer:\n\n```bash\nvxil api POST /v1/ai/templates --data \'{\n "template": "support-answer",\n "system": "You are a support agent. Answer ONLY from the context. If the context does not contain the answer, say you do not know.",\n "user": "Context:\\n{{context}}\\n\\nCustomer question:\\n{{query}}\\n\\nWrite a short, direct reply."\n}\'\n# 201 { "data": { "template": "support-answer", "version": 1 } }\n```\n\nRe-POST the same name and you get version 2 \u2014 old versions stay pinnable. `{{query}}` and\n`{{context}}` are what the retrieval step fills in. **A grounded answer with no template is a 404**,\nso this is step zero, not an optional flourish.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `ai:read ai:write rag:read rag:write vector-search:read\nvector-search:write cms:read cms:write copilot:read copilot:write webhooks:read functions:invoke\nfeatures:read`.\n\n**1. The index.** The `kb` cms collection is what you author in; the `kb` vector collection is what\nretrieval reads. Create the index, then push an article into it:\n\n```bash\nvxil api POST /v1/search/collections --data \'{"collection":"kb","dimensions":1536}\'\n# 201 { "data": { "collection": "kb", "dimensions": 1536, "backend": "\u2026" } }\n# (`vector-search.sync` also reconciles one scheduled job per entry \u2014 you can see it in\n# `GET /v1/jobs/schedules` as `vs-sync:cms~kb~kb`, on the cron you declared.)\n\nvxil api POST /v1/rag/ingest/kb --data \'{\n "doc_id": "how-refunds-work",\n "text": "A refund is issued to the original payment method within 14 days of purchase. Ask the customer for the order id, confirm the purchase date, then issue the refund from the billing screen. Refunds are not available after 14 days.",\n "metadata": { "topic": "billing" }\n}\'\n# 202 { "data": { "doc_id": "how-refunds-work", "status": "indexed", "chunks": 1, "embedding_tokens": \u2026 } }\n```\n\n`POST /v1/rag/ingest/{collection}` is a convenience: a key holding only `rag:write` can fill the\nindex without also holding a vector-search scope.\n\nYou do not have to remember to do that twice, though \u2014 `vector-search.sync` in `vxil.config.ts`\ndeclares the `kb` cms collection as a source, so published articles are embedded on a schedule and\nthe index never silently drifts from the content. The direct ingest above just saves you the wait.\n\n**2. Classification, on every new ticket.** Create one and watch the hook:\n\n```bash\nvxil api POST /v1/cms/items/tickets --data \'{"data":{"subject":"Billing: charged twice this month","requester":"u_ana","state":"open","body":"My card was charged twice on the 3rd. Can I get one of them back?"}}\'\n# 201 { "data": { "item_id": "itm_\u2026", \u2026 } }\n\n# a moment later\nvxil api GET /v1/cms/items/tickets/itm_\u2026\n# 200 \u2026 "data": { "subject": "Billing: charged twice this month", "category": "billing", "state": "open", \u2026 }\n```\n\n`triage-ticket` fired on the write, re-fetched the row (a hook delivery carries ids, not the\ndocument), and asked for a **forced-label verdict**:\n\n```bash\nvxil api POST /v1/ai/classify --data \'{"input":"Billing: charged twice this month","labels":["billing","bug","how_to","other"]}\'\n# 200 { "data": { "generation_id": "gen_\u2026", "label": "billing", "confidence": 0.9,\n# "rationale": "\u2026", "usage": { \u2026 }, "cached": false } }\n```\n\nThe label set is part of the request, so the answer is constrained to it by the schema \u2014 the model\ncannot invent a fifth category or reply with a sentence. The function is also idempotent by\ninspection: a ticket that already has a `category` is skipped, because hook delivery is\nat-least-once and a redelivery should not cost another model call.\n\n**3. A grounded, cited draft \u2014 and a second opinion on it.** Press the record\'s button:\n\n```bash\nvxil api POST /v1/cms/items/tickets/itm_\u2026/actions/draft_reply\n# 200 { "data": { "collection": "tickets", "item_id": "itm_\u2026", "action": "draft_reply",\n# "fn": "draft-reply",\n# "result": { "draft": "\u2026", "score": 10, "verdict": "pass",\n# "citations": [ { "chunk_id": "how-refunds-work#0",\n# "doc_id": "how-refunds-work", "score": 0.0164 } ],\n# "written": true } } }\n```\n\nTwo calls happened inside, and the split is the lesson:\n\n```bash\nvxil api POST /v1/rag/answer --data \'{"query":"My card was charged twice. Can I get one back?","collection":"kb","top_k":5,"stream":false}\'\n# 200 { "data": { "answer": "\u2026",\n# "citations": [ { "chunk_id": "how-refunds-work#0", "doc_id": "how-refunds-work", "score": 0.0164 } ],\n# "usage": { "retrieval_ms": 21, "retrieved": 1, "used": 1, \u2026 }, "finish": "stop" } }\n\nvxil api POST /v1/ai/judge --data \'{\n "input": "My card was charged twice. Can I get one back?",\n "candidate": "Refunds go back to the original payment method within 14 days of purchase.",\n "criteria": [ { "name": "answers the question asked", "weight": 2 },\n { "name": "is supported by the cited text", "weight": 2 } ],\n "scale": { "min": 0, "max": 10 } }\'\n# 200 { "data": { "generation_id": "gen_\u2026", "score": 10, "verdict": "pass", "rationale": "\u2026", \u2026 } }\n```\n\n`stream: false` is load-bearing. With streaming on (the default), this route answers with a\n`generation_id`, a channel, a token and a `resume_path` for a browser to attach to \u2014 the citations\narrive immediately and the text streams. A server-side step wants the finished text, so it asks for\nit. Getting this wrong is a silent empty draft, not an error.\n\n`citations` are the chunks that actually **survived the context budget** \u2014 not everything retrieved.\nThat distinction is what makes them auditable: every sentence in the draft is traceable to text in\nthe list. And the score is a forced integer on a fixed scale, so drafts are comparable to each\nother rather than each getting its own adjective.\n\nNothing was sent to a customer. The action writes `draft` and `draft_score` onto the ticket and\nstops \u2014 the last step is a person.\n\n**4. The assistant: propose, then confirm.** The copilot answers from the same index and, when a\nturn would *write*, stops and asks:\n\n```bash\nvxil api POST /v1/copilot/desk/messages --data \'{"user_id":"u_agent","message":"What is our refund window?"}\'\n# 200 { "data": { "conversation_id": "cnv_\u2026", "message_id": "msg_\u2026",\n# "answer": "Refunds are available within 14 days of purchase\u2026",\n# "action_status": "none", "citations": [ \u2026 ], \u2026 } }\n\nvxil api POST /v1/copilot/desk/messages --data \'{"conversation_id":"cnv_\u2026","user_id":"u_agent","message":"Open a ticket for Ana about the double charge."}\'\n# 200 { "data": { "message_id": "msg_\u2026", "action_status": "proposed",\n# "proposal": { "message_id": "msg_\u2026", "tool": "cms_create_item",\n# "args": { "collection": "tickets", "data": { "subject": "\u2026", \u2026 } },\n# "feature": "cms", "proposed_at": "\u2026", "require_confirm": true,\n# "confirm_path": "/v1/copilot/conversations/cnv_\u2026/actions/msg_\u2026/confirm" },\n# \u2026 } }\n```\n\nOn the **mock** provider that second turn answers `action_status: "none"` \u2014 the mock does not decide\nto call a tool on its own. Steer it with the marker the platform\'s own end-to-end tests use, and the\nturn produces a real proposal you can confirm:\n\n```text\nOpen a ticket for Ana about the double charge.\n[[tool_call:cms_create_item {"collection":"tickets","data":{"subject":"Double charge for Ana","requester":"u_ana","state":"open"}}]]\n```\n\nNothing has been written yet. The proposal names the tool and the exact arguments, and hands you\nthe confirm path. Commit it:\n\n```bash\nvxil api POST /v1/copilot/conversations/cnv_\u2026/actions/msg_\u2026/confirm\n# 200 { "data": { "message_id": "msg_\u2026", "proposal_message_id": "msg_\u2026", "action_status": "confirmed",\n# "confirmed_at": "\u2026", "result": { "data": { "item_id": "itm_\u2026", \u2026 } } } }\n```\n\nConfirm takes **no body** \u2014 the ids in the path are the whole request, which is what makes the\nlatch tamper-proof: you cannot confirm a *different* write than the one you were shown. Call it\ntwice and the second answers `already: true`. Wait fifteen minutes and it is\n`410 proposal_expired`. And the permission check runs **again at confirm time**, so a scope revoked\nbetween proposal and confirm stops the write.\n\nThe keys under `copilot.agents.desk.actions.allow` are tool names from the catalog \u2014\n`cms_query_items` (a read, run inline) and `cms_create_item` / `cms_run_item_action` (writes,\nproposed). An unknown key there is inert, never invented.\n\n**5. The same backend, as tools.** Point an agent at it:\n\n```bash\nvxil mcp install --client claude --scopes features:read,cms:read,ai:write,rag:read,vector-search:read,webhooks:read\n```\n\nThat mints a dedicated, `agent`-tagged, revocable key and writes the MCP server entry for your\nclient. Three layers decide what the agent can do, and they compose:\n\n1. **`mcp.exposureLevel: \'custom\'` + `allowToolList`** in this config \u2014 the tenant-wide surface.\n2. **the key\'s scopes** \u2014 what the underlying REST route will accept.\n3. **the key\'s `allowed_tools` / `denied_tools`** \u2014 a per-key narrowing on top, editable after\n minting without rotating the key.\n\n`features:read` is load-bearing: without it the policy probe (`GET /v1/config/mcp`) is refused and\nthe agent sees **zero** tools with no obvious error. Mint least privilege, but not less than that.\n\n**6. What can I react to here?** The last function answers the question an agent always has to ask\na human today:\n\n```bash\nvxil functions invoke agent-capabilities\n# { "catalog_events": 170,\n# "enabled_features": [ "ai", "cms", "copilot", "functions", "mcp", "rag", "vector-search", "webhooks" ],\n# "reactable_prefixes": [ { "prefix": "cms.item.", "count": \u2026 }, { "prefix": "ai.", "count": \u2026 }, \u2026 ],\n# "failure_events": [ "job.dead_lettered", "jobs.schedule.missed",\n# "webhooks.delivery.dead_lettered", \u2026 ],\n# "how_to_subscribe": "POST /v1/webhooks/subscriptions \u2026" }\n```\n\nIt reads `GET /v1/webhooks/events/catalog` \u2014 the machine-readable list of every lifecycle and\nfailure event the platform writes, with a prefix roll-up \u2014 and folds it against the features this\nbackend actually has on. The agent can call the catalog itself, too: `webhooks_event_catalog` is in\nthe tool list above, which is the difference between an agent that *has* tools and one that can\n**discover** what the system will tell it.\n\nActing on that discovery is one call with a key that carries `webhooks:write` \u2014 deliberately not\nthe read-only key this function holds:\n\n```bash\nvxil api POST /v1/webhooks/subscriptions --data \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["cms.item.","ai."]}\'\n```\n\n## What to learn from this\n\n- **Forcing the shape is the feature.** Classify returns one of *your* labels; judge returns an\n integer in *your* range. Most "the model went off the rails" problems are a missing schema, not a\n missing instruction.\n- **Two passes beat one long prompt.** Writing and evaluating are different jobs, and separating\n them gives you a number you can threshold, chart and regress against.\n- **Grounding is a pipeline, not a prompt trick.** Retrieval, a context budget, and citations of\n the chunks that survived it \u2014 the answer is auditable because the pipeline kept the receipts.\n- **Propose \u2192 confirm is where agent safety actually lives.** Not in a system prompt asking the\n model to be careful: in a latch that persists the exact arguments, re-checks permission at commit\n time, expires, and executes at most once.\n- **Least privilege for an agent is three layers, not one.** The tenant\'s exposure list, the key\'s\n scopes, and the key\'s per-tool narrowing \u2014 each can be tightened without touching the others.\n- **An agent should be able to ask the backend what it can do.** A tool catalog and an event\n catalog are both machine-readable for the same reason: the alternative is a prompt that goes stale\n the next time you ship.\n\n**Pairs with:** `templates/ai-journal/` (enrichment on write, and asking your own data questions)\nand `templates/helpdesk/` (the same desk without the AI half).\n',
|
|
8780
8794
|
"functions": {
|
|
8781
8795
|
"agent-capabilities.ts": "// agent-capabilities.ts \u2014 \"WHAT CAN I REACT TO HERE?\" (a vxil function).\n//\n// Trigger: http. An agent (or your own onboarding screen) calls this once and\n// learns, from the backend itself, what this workspace can emit \u2014 instead of a\n// human pasting a list into a prompt that goes stale the next release.\n//\n// Two reads, folded together:\n// \u2022 `GET /v1/webhooks/events/catalog` \u2014 the machine-readable list of every\n// lifecycle and failure event the platform writes, with a `prefixes` roll-up\n// you can subscribe to directly.\n// \u2022 `GET /v1/features` \u2014 which features THIS backend actually has on.\n// The answer is the intersection: the prefixes worth subscribing to here.\n//\n// WHY A KEY AND NOT THE FUNCTION'S OWN CALLBACK: a function's scoped callback\n// covers the feature APIs (cms, ai, rag, \u2026). The event catalog and the feature\n// list are platform reads, so this uses the narrowest key that can reach them \u2014\n// one holding only `features:read` and `webhooks:read`, stored as a secret,\n// resolved per invocation, revocable in one click without a redeploy.\n//\n// To actually SUBSCRIBE, POST to /v1/webhooks/subscriptions with\n// { target_url, event_prefixes } using a key that carries `webhooks:write` \u2014\n// deliberately NOT this one (see the README).\n\n/** prefix segment \u2192 the feature key it belongs to, where the names differ. */\nconst PREFIX_FEATURE: Record<string, string> = {\n job: 'jobs', jobs: 'jobs', user: 'auth', auth: 'auth', session: 'auth',\n org: 'orgs', orgs: 'orgs', rate_limits: 'rate-limits', feeds: 'activity-feed',\n 'vector-search': 'vector-search', functions: 'functions',\n};\n\ninterface Env {\n vxil_base?: string;\n secrets?: Record<string, string>;\n payload?: { all?: boolean };\n}\ninterface CatalogEvent { name?: string; feature?: string; level?: string }\ninterface Prefix { prefix?: string; count?: number }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const key = env.secrets?.vxil_read_key;\n if (!key) {\n return Response.json(\n { error: 'missing_secret', message: 'set the vxil_read_key secret first' },\n { status: 503 },\n );\n }\n const h = { authorization: `Bearer ${key}` };\n\n const [catRes, featRes] = await Promise.all([\n fetch(`${base}/v1/webhooks/events/catalog`, { headers: h }),\n fetch(`${base}/v1/features`, { headers: h }),\n ]);\n if (!catRes.ok) {\n return Response.json({ error: 'catalog_unavailable', status: catRes.status }, { status: 502 });\n }\n const cat = ((await catRes.json()) as {\n data?: { count?: number; events?: CatalogEvent[]; prefixes?: Prefix[] };\n }).data ?? {};\n const enabled = new Set(\n featRes.ok\n ? ((await featRes.json()) as { data?: { features?: string[] } }).data?.features ?? []\n : [],\n );\n\n const all = env.payload?.all === true;\n const prefixes = (cat.prefixes ?? []).filter((p) => {\n if (all || enabled.size === 0) return true;\n const head = String(p.prefix ?? '').replace(/\\.$/, '');\n return enabled.has(PREFIX_FEATURE[head] ?? head);\n });\n\n // The failure half is the half worth wiring first: it is what tells you the\n // backend is unhappy before a customer does.\n const failures = (cat.events ?? [])\n .filter((e) => e.level === 'failure')\n .map((e) => e.name)\n .filter((n): n is string => typeof n === 'string')\n .sort();\n\n return Response.json({\n catalog_events: cat.count ?? (cat.events ?? []).length,\n enabled_features: [...enabled].sort(),\n reactable_prefixes: prefixes,\n failure_events: failures,\n how_to_subscribe:\n 'POST /v1/webhooks/subscriptions { \"target_url\": \"https://\u2026\", \"event_prefixes\": [\"job.\", \"cms.item.\"] } '\n + 'with a key carrying webhooks:write',\n });\n },\n};\n",
|
|
8782
|
-
"draft-reply.ts": "// draft-reply.ts \u2014 RETRIEVE \u2192 GROUND \u2192 SCORE (a vxil function).\n//\n// Trigger: the per-record action `draft_reply` on `tickets`. The action envelope\n// carries the WHOLE row, so this step needs no re-fetch:\n// { collection, item_id, action, actor, item: { item_id, status, version, data } }\n//\n// Three calls, three jobs, in order:\n// 1. `POST /v1/rag/answer` \u2014 retrieve from the `kb` index and answer ONLY from\n// what came back, returning the chunks it used as citations. A grounded\n// answer you can audit beats a confident one you cannot.\n// 2. `POST /v1/ai/judge` \u2014 score that draft against a rubric, as an integer on\n// a fixed scale. The model that writes is not the authority on whether the\n// writing is good; a second, schema-forced pass is.\n// 3. one PATCH \u2014 persist the draft + its score so a human decides what to send.\n//\n// Nothing here sends anything to a customer. The last step is always a person.\n\nconst CRITERIA = [\n { name: 'answers the question asked', weight: 2 },\n { name: 'is supported by the cited knowledge-base text', weight: 2 },\n { name: 'is concise and free of speculation', weight: 1 },\n];\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: {\n collection?: string;\n item_id?: string;\n item?: { data?: { subject?: string; body?: string; category?: string } };\n };\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const rag = env.scoped_jwts?.rag;\n const ai = env.scoped_jwts?.ai;\n const itemId = env.payload?.item_id;\n if (!cms || !rag || !ai || !itemId || env.payload?.collection !== 'tickets') {\n return Response.json({ error: 'bad_request', message: 'not a tickets action' }, { status: 400 });\n }\n\n const t = env.payload?.item?.data ?? {};\n const question = `${t.subject ?? ''}\\n\\n${t.body ?? ''}`.trim();\n if (!question) return Response.json({ error: 'empty_ticket' }, { status: 422 });\n\n // 1. GROUNDED ANSWER. `template` falls back to the rag config's\n // `defaultTemplate`, so the call stays this short. `stream: false` is\n // load-bearing: with streaming enabled (the default) this route answers\n // with a channel + resume path for a browser to attach to, NOT the text.\n // A server-side step wants the text, so it says so.\n const answered = await fetch(`${base}/v1/rag/answer`, {\n method: 'POST',\n headers: { authorization: `Bearer ${rag}`, 'content-type': 'application/json' },\n body: JSON.stringify({ query: question, collection: 'kb', top_k: 5, stream: false }),\n });\n if (!answered.ok) {\n const detail = await answered.text();\n return Response.json(\n { error: 'retrieval_failed', status: answered.status, detail: detail.slice(0, 300) },\n { status: 502 },\n );\n }\n const grounded = ((await answered.json()) as {\n data?: { answer?: string; citations?: unknown[]; usage?: unknown };\n }).data ?? {};\n const draft = String(grounded.answer ?? '').trim();\n const citations = Array.isArray(grounded.citations) ? grounded.citations : [];\n if (!draft) return Response.json({ error: 'empty_draft' }, { status: 502 });\n\n // 2. SCORE IT. A forced integer on a fixed scale \u2014 comparable across drafts,\n // unlike \"this looks good\".\n const scored = await fetch(`${base}/v1/ai/judge`, {\n method: 'POST',\n headers: { authorization: `Bearer ${ai}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n input: question,\n candidate: draft,\n criteria: CRITERIA,\n scale: { min: 0, max: 10 },\n }),\n });\n const verdict = scored.ok\n ? ((await scored.json()) as { data?: { score?: number; verdict?: string; rationale?: string } }).data ?? {}\n : {};\n const score = typeof verdict.score === 'number' ? Math.round(verdict.score) : null;\n\n // 3. PERSIST. A human reads it, edits it, and decides whether it is sent.\n const patch = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: { draft, ...(score === null ? {} : { draft_score: score }), state: 'drafted' },\n }),\n });\n\n return Response.json({\n item_id: itemId,\n draft,\n score,\n verdict: verdict.verdict ?? null,\n rationale: verdict.rationale ?? null,\n citations,\n written: patch.ok,\n });\n },\n};\n",
|
|
8783
|
-
"triage-ticket.ts": "// triage-ticket.ts \u2014 CLASSIFY EVERY NEW TICKET (a vxil function).\n//\n// Trigger: cmsHook on `tickets`. A hook delivery carries ids, not the row\n// ({ event, collection, item_id }), so the function RE-FETCHES the ticket\n// rather than trusting inline fields \u2014 and delivery is at-least-once, so it\n// skips a ticket that already carries a category instead of re-billing a model\n// call on a redelivery.\n//\n// The one model call is a FORCED-LABEL verdict: `POST /v1/ai/classify` takes the\n// label set and returns exactly one of them (plus a confidence and a one-line\n// rationale). That is the difference between a classifier and a chat prompt \u2014\n// the answer cannot be a paragraph, a new label, or an apology.\n\nconst LABELS = ['billing', 'bug', 'how_to', 'other'];\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\ninterface TicketData { subject?: string; body?: string; category?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const itemId = env.payload?.item_id;\n if (!cms || !ai || !itemId || env.payload?.collection !== 'tickets') {\n return Response.json({ skipped: true, reason: 'not a tickets hook' });\n }\n // The cms.item.* subscription also delivers updates \u2014 only triage a create.\n if (!String(env.payload?.event ?? '').endsWith('.created')) {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n\n const read = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!read.ok) return Response.json({ skipped: true, reason: `fetch ${read.status}` });\n const ticket = ((await read.json()) as { data?: { data?: TicketData } }).data?.data ?? {};\n // Already triaged \u21D2 this is a redelivery. Do nothing (and pay for nothing).\n if (ticket.category) {\n return Response.json({ skipped: true, reason: 'already triaged', category: ticket.category });\n }\n\n const input = `${ticket.subject ?? ''}\\n\\n${ticket.body ?? ''}`.trim();\n if (!input) return Response.json({ skipped: true, reason: 'empty ticket' });\n\n const verdict = await fetch(`${base}/v1/ai/classify`, {\n method: 'POST',\n headers: { authorization: `Bearer ${ai}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n input,\n labels: LABELS,\n rubric:\n 'billing = money, invoices, refunds or subscriptions. '\n + 'bug = something is broken or behaves incorrectly. '\n + 'how_to = the customer is asking how to do something. '\n + 'other = anything else.',\n }),\n });\n if (!verdict.ok) {\n const detail = await verdict.text();\n return Response.json(\n { error: 'classify_failed', status: verdict.status, detail: detail.slice(0, 300) },\n { status: 502 },\n );\n }\n const v = ((await verdict.json()) as {\n data?: { label?: string; confidence?: number; rationale?: string };\n }).data ?? {};\n const label = LABELS.includes(String(v.label)) ? String(v.label) : 'other';\n\n const patch = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { category: label, state: 'open' } }),\n });\n\n return Response.json({\n item_id: itemId,\n category: label,\n confidence: v.confidence ?? null,\n rationale: v.rationale ?? null,\n written: patch.ok,\n });\n },\n};\n"
|
|
8796
|
+
"draft-reply.ts": "// draft-reply.ts \u2014 RETRIEVE \u2192 GROUND \u2192 SCORE (a vxil function).\n//\n// Trigger: the per-record action `draft_reply` on `tickets`. The action envelope\n// carries the WHOLE row, so this step needs no re-fetch:\n// { collection, item_id, action, actor, item: { item_id, status, version, data } }\n//\n// Three calls, three jobs, in order:\n// 1. `POST /v1/rag/answer` \u2014 retrieve from the `kb` index and answer ONLY from\n// what came back, returning the chunks it used as citations. A grounded\n// answer you can audit beats a confident one you cannot.\n// 2. `POST /v1/ai/judge` \u2014 score that draft against a rubric, as an integer on\n// a fixed scale. The model that writes is not the authority on whether the\n// writing is good; a second, schema-forced pass is.\n// 3. one PATCH \u2014 persist the draft + its score so a human decides what to send.\n//\n// Nothing here sends anything to a customer. The last step is always a person.\n\nconst CRITERIA = [\n { name: 'answers the question asked', weight: 2 },\n { name: 'is supported by the cited knowledge-base text', weight: 2 },\n { name: 'is concise and free of speculation', weight: 1 },\n];\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: {\n collection?: string;\n item_id?: string;\n item?: { data?: { subject?: string; body?: string; category?: string } };\n };\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const rag = env.scoped_jwts?.rag;\n const ai = env.scoped_jwts?.ai;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (functions.md \xA73); this guard stays as belt-and-braces.\n if (!cms || !rag || !ai || !itemId || env.payload?.collection !== 'tickets') {\n return Response.json({ error: 'bad_request', message: 'not a tickets action' }, { status: 400 });\n }\n\n const t = env.payload?.item?.data ?? {};\n const question = `${t.subject ?? ''}\\n\\n${t.body ?? ''}`.trim();\n if (!question) return Response.json({ error: 'empty_ticket' }, { status: 422 });\n\n // 1. GROUNDED ANSWER. `template` falls back to the rag config's\n // `defaultTemplate`, so the call stays this short. `stream: false` is\n // load-bearing: with streaming enabled (the default) this route answers\n // with a channel + resume path for a browser to attach to, NOT the text.\n // A server-side step wants the text, so it says so.\n const answered = await fetch(`${base}/v1/rag/answer`, {\n method: 'POST',\n headers: { authorization: `Bearer ${rag}`, 'content-type': 'application/json' },\n body: JSON.stringify({ query: question, collection: 'kb', top_k: 5, stream: false }),\n });\n if (!answered.ok) {\n const detail = await answered.text();\n return Response.json(\n { error: 'retrieval_failed', status: answered.status, detail: detail.slice(0, 300) },\n { status: 502 },\n );\n }\n const grounded = ((await answered.json()) as {\n data?: { answer?: string; citations?: unknown[]; usage?: unknown };\n }).data ?? {};\n const draft = String(grounded.answer ?? '').trim();\n const citations = Array.isArray(grounded.citations) ? grounded.citations : [];\n if (!draft) return Response.json({ error: 'empty_draft' }, { status: 502 });\n\n // 2. SCORE IT. A forced integer on a fixed scale \u2014 comparable across drafts,\n // unlike \"this looks good\".\n const scored = await fetch(`${base}/v1/ai/judge`, {\n method: 'POST',\n headers: { authorization: `Bearer ${ai}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n input: question,\n candidate: draft,\n criteria: CRITERIA,\n scale: { min: 0, max: 10 },\n }),\n });\n const verdict = scored.ok\n ? ((await scored.json()) as { data?: { score?: number; verdict?: string; rationale?: string } }).data ?? {}\n : {};\n const score = typeof verdict.score === 'number' ? Math.round(verdict.score) : null;\n\n // 3. PERSIST. A human reads it, edits it, and decides whether it is sent.\n const patch = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: { draft, ...(score === null ? {} : { draft_score: score }), state: 'drafted' },\n }),\n });\n\n return Response.json({\n item_id: itemId,\n draft,\n score,\n verdict: verdict.verdict ?? null,\n rationale: verdict.rationale ?? null,\n citations,\n written: patch.ok,\n });\n },\n};\n",
|
|
8797
|
+
"triage-ticket.ts": "// triage-ticket.ts \u2014 CLASSIFY EVERY NEW TICKET (a vxil function).\n//\n// Trigger: cmsHook on `tickets`. A hook delivery carries ids, not the row\n// ({ event, collection, item_id }), so the function RE-FETCHES the ticket\n// rather than trusting inline fields \u2014 and delivery is at-least-once, so it\n// skips a ticket that already carries a category instead of re-billing a model\n// call on a redelivery.\n//\n// The one model call is a FORCED-LABEL verdict: `POST /v1/ai/classify` takes the\n// label set and returns exactly one of them (plus a confidence and a one-line\n// rationale). That is the difference between a classifier and a chat prompt \u2014\n// the answer cannot be a paragraph, a new label, or an apology.\n\nconst LABELS = ['billing', 'bug', 'how_to', 'other'];\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\ninterface TicketData { subject?: string; body?: string; category?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (functions.md \xA73); this guard stays as belt-and-braces.\n if (!cms || !ai || !itemId || env.payload?.collection !== 'tickets') {\n return Response.json({ skipped: true, reason: 'not a tickets hook' });\n }\n // The cms.item.* subscription also delivers updates \u2014 only triage a create.\n if (!String(env.payload?.event ?? '').endsWith('.created')) {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n\n const read = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!read.ok) return Response.json({ skipped: true, reason: `fetch ${read.status}` });\n const ticket = ((await read.json()) as { data?: { data?: TicketData } }).data?.data ?? {};\n // Already triaged \u21D2 this is a redelivery. Do nothing (and pay for nothing).\n if (ticket.category) {\n return Response.json({ skipped: true, reason: 'already triaged', category: ticket.category });\n }\n\n const input = `${ticket.subject ?? ''}\\n\\n${ticket.body ?? ''}`.trim();\n if (!input) return Response.json({ skipped: true, reason: 'empty ticket' });\n\n const verdict = await fetch(`${base}/v1/ai/classify`, {\n method: 'POST',\n headers: { authorization: `Bearer ${ai}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n input,\n labels: LABELS,\n rubric:\n 'billing = money, invoices, refunds or subscriptions. '\n + 'bug = something is broken or behaves incorrectly. '\n + 'how_to = the customer is asking how to do something. '\n + 'other = anything else.',\n }),\n });\n if (!verdict.ok) {\n const detail = await verdict.text();\n return Response.json(\n { error: 'classify_failed', status: verdict.status, detail: detail.slice(0, 300) },\n { status: 502 },\n );\n }\n const v = ((await verdict.json()) as {\n data?: { label?: string; confidence?: number; rationale?: string };\n }).data ?? {};\n const label = LABELS.includes(String(v.label)) ? String(v.label) : 'other';\n\n const patch = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { category: label, state: 'open' } }),\n });\n\n return Response.json({\n item_id: itemId,\n category: label,\n confidence: v.confidence ?? null,\n rationale: v.rationale ?? null,\n written: patch.ok,\n });\n },\n};\n"
|
|
8784
8798
|
}
|
|
8785
8799
|
},
|
|
8786
8800
|
{
|
|
@@ -9051,7 +9065,7 @@ export default defineConfig({
|
|
|
9051
9065
|
"functions": {
|
|
9052
9066
|
"abandoned-cart.ts": "// abandoned-cart.ts \u2014 RETENTION CRON (a vxil function, \xA77.3).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep open carts that went stale (last_activity\n// older than 1h) using the slot-indexed range filter, and nudge the shopper. This is the\n// jobs-cron pattern \u2014 no new primitive, just a scheduled function.\n\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string> }\ninterface Cart { status: string; last_activity: string; end_user?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // carts still `open` whose last_activity is > 1h ago (t1 range filter, index-served)\n const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString();\n const filter = enc({ status: 'open', last_activity: { $lt: cutoff } });\n const res = await fetch(`${base}/v1/cms/items/carts?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: Cart }[] } };\n const carts = body.data?.items ?? [];\n\n // POST /v1/notifications/send is { user_id, template, data } \u2014 `transactional` is the\n // shipped generic template (requires data.subject + data.paragraph, notifications.md \xA77).\n let nudged = 0;\n for (const c of carts) {\n if (!notif || !c.data.end_user) continue;\n await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${notif}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_id: c.data.end_user,\n template: 'transactional',\n data: {\n subject: 'You left items in your cart',\n paragraph: `Your cart (${c.item_id}) is still waiting \u2014 come back and finish checkout any time.`,\n },\n }),\n });\n nudged++;\n }\n return Response.json({ scanned: carts.length, nudged });\n },\n};\n\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n",
|
|
9053
9067
|
"checkout.ts": "// checkout.ts \u2014 THE CHECKOUT SAGA (a vxil function, \xA77.3).\n//\n// The hard part of e-commerce: \"reserve N SKUs + capture payment + create the order,\n// all-or-nothing\" \u2014 which is the deliberately-REJECTED cross-feature-ACID case. The\n// doctrinal (and incumbent-identical) answer is a reserve\u2192settle\u2192reverse SAGA, and it\n// is exactly-once under any concurrency. Shopify+Stripe do the same thing (Stripe is a\n// physically separate system reconciled by webhook); nothing here is a platform gap.\n//\n// Invoke it SERVER-SIDE (your backend POSTs /v1/fn/checkout with a server key):\n// under cms.strictEndUserScope an end-user-mode invocation is correctly denied on\n// the shared collections this saga touches (cart_items/variants) \u2014 inventory is a\n// tenant-wide surface, so the reserve step is server work by design.\n//\n// Steps:\n// 1. read the cart (owner + currency) + its lines (cms:read)\n// 2. RESERVE each line: PATCH variant {$inc:{stock:-qty}} \u2014 validation.min:0 makes it a\n// single-statement oversell-safe decrement (409 inc_out_of_bounds if insufficient).\n// On any failure \u2192 COMPENSATE (re-$inc the ones already reserved) \u2192 409 out_of_stock.\n// 3. create the ORDER with a unique cart_ref \u2192 EXACTLY-ONCE (409 on a racing duplicate).\n// 4. open a payments checkout-session (mode:payment, Idempotency-Key = order number).\n// 5. return { order_id, checkout_url }. Capture completes async \u2192 functions/on-order-paid.ts.\n// (Not shipped here: if payment never completes, schedule a jobs `deliver_after`\n// release that re-$inc's the reserve.)\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n end_user?: { id: string };\n // the caller's HTTP body rides the invocation envelope under `payload` (functions.md \xA72)\n payload?: { cart_id?: string; success_url?: string; cancel_url?: string };\n}\ninterface Line { variant: string; qty: number; unit_price_cents: number; price_ref: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const pay = env.scoped_jwts?.payments;\n if (!cms || !pay) return json({ error: 'missing cms/payments scope' }, 403);\n const { cart_id, success_url, cancel_url } = env.payload ?? {};\n if (!cart_id) return json({ error: 'cart_id required' }, 400);\n\n const H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n\n // 1. read the cart (its end_user owner + currency), then its lines\n const cartRes = await fetch(`${base}/v1/cms/items/carts/${cart_id}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!cartRes.ok) return json({ error: 'cart_not_found' }, 404);\n const cart = ((await cartRes.json()) as { data?: { data?: { end_user?: string; currency?: string } } }).data?.data ?? {};\n const shopper = env.end_user?.id ?? cart.end_user;\n if (!shopper) return json({ error: 'cart has no owner (end_user)' }, 400);\n const lines = await get<Line>(`${base}/v1/cms/items/cart_items?filter=${enc({ cart: cart_id })}&limit=100`, cms);\n if (lines.length === 0) return json({ error: 'empty cart' }, 400);\n\n // 2. RESERVE inventory line-by-line (oversell-safe $inc). Track for compensation.\n const reserved: Line[] = [];\n for (const ln of lines) {\n const r = await fetch(`${base}/v1/cms/items/variants/${ln.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: -ln.qty } }),\n });\n if (!r.ok) {\n // compensate everything reserved so far, then fail cleanly\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'out_of_stock', variant: ln.variant }, 409);\n }\n reserved.push(ln);\n }\n\n // 3. create the ORDER \u2014 unique cart_ref makes placement exactly-once under concurrency.\n const total = lines.reduce((s, l) => s + l.unit_price_cents * l.qty, 0);\n const number = `ORD-${cart_id.slice(0, 8)}`;\n const orderRes = await fetch(`${base}/v1/cms/items/orders`, {\n method: 'POST', headers: H(cms),\n body: JSON.stringify({\n data: {\n number, cart_ref: cart_id, status: 'pending', end_user: shopper,\n total_cents: total, placed_at: new Date().toISOString(), lines,\n },\n }),\n });\n if (orderRes.status === 409) {\n // a concurrent checkout already placed this cart \u2192 idempotent: report it placed\n return json({ status: 'already_placed', number }, 200);\n }\n if (!orderRes.ok) {\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'order_create_failed' }, 502);\n }\n const order = (await orderRes.json()) as { data?: { item_id?: string } };\n\n // 4. open the hosted payment (one-time). Idempotency-Key = order number \u21D2 safe to retry.\n // The documented checkout-sessions contract (payments.md \xA73): user_id + line_items\n // [{ price_ref, quantity, amount_cents?, currency? }] + mode + success/cancel URLs.\n // price_ref is the PROVIDER's price id (a Stripe Price) snapshot on the cart line \u2014\n // Stripe's adapter charges by price id; amount_cents/currency serve amount-based\n // providers (PayPal payment mode). NOTE: the shipped Stripe adapter charges\n // line_items[0] only \u2014 for multi-line carts on Stripe, collapse to one provider\n // line (or one order-total price) before opening the session.\n const currency = cart.currency ?? 'usd';\n const sess = await fetch(`${base}/v1/payments/checkout-sessions`, {\n method: 'POST',\n headers: { ...H(pay), 'idempotency-key': number },\n body: JSON.stringify({\n user_id: shopper,\n mode: 'payment',\n line_items: lines.map((l) => ({ price_ref: l.price_ref, quantity: l.qty, amount_cents: l.unit_price_cents, currency })),\n success_url: success_url ?? 'https://storefront.example/checkout/success',\n cancel_url: cancel_url ?? 'https://storefront.example/checkout/cancel',\n }),\n });\n if (!sess.ok) {\n // the order stays placed (pending) \u2014 surface the payment error so the caller can\n // retry the session (same Idempotency-Key) after fixing price_refs / provider keys.\n return json({ order_id: order.data?.item_id, number, error: 'payment_session_failed' }, 502);\n }\n const s = (await sess.json()) as { data?: { url?: string } };\n\n return json({ order_id: order.data?.item_id, number, checkout_url: s.data?.url }, 201);\n },\n};\n\n// \u2500\u2500 tiny helpers (the vxil REST envelope is { data: { items }, meta }; items carry item_id) \u2500\u2500\nasync function get<T>(url: string, jwt: string): Promise<T[]> {\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: T }[] } };\n return (body.data?.items ?? []).map((i) => ({ id: i.item_id, ...i.data } as T));\n}\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
|
|
9054
|
-
"on-order-paid.ts": "// on-order-paid.ts \u2014 SETTLEMENT SIDE-EFFECTS (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.updated for `orders`. When the order flips to\n// `paid` \u2014 YOUR payment-success handler PATCHes it (e.g. a function subscribed to the\n// payments `payments.charge.succeeded` event via a webhooks-out subscription on the\n// `payments.` prefix, or your backend after the hosted checkout returns); the\n// order_transition hook validates the flip \u2014 fan out the side-effects:\n// email the receipt (notifications) and POST the fulfillment webhook to the tenant's\n// 3PL/warehouse over the egress allowlist. Delivery is at-least-once with retry/DLQ \u2014\n// identical semantics to Shopify Flow / a Stripe webhook fan-out.\n//\n// The cms-hook payload is { event, collection, item_id } \u2014 NOT the row \u2014 so the\n// function RE-FETCHES the order by id (through the edge, tenant-scoped). notifications:send\n// is a legitimate function scope (allowed by the deploy; https://vxil.com/docs/guide/08-running-your-code-functions).\n//\n// (Inventory was already reserved atomically at checkout, so there is no decrement here \u2014\n// the reservation simply becomes permanent. A payment FAILURE path compensates instead.)\n\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string>; payload?: { collection?: string; item_id?: string } }\ninterface OrderData { number?: string; status?: string; total_cents?: number; end_user?: string }\ninterface Item { data?: { data?: OrderData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n if (env.payload?.collection !== 'orders' || !cms || !itemId) return Response.json({ skipped: true });\n\n // Re-fetch the order (the payload carries only the id) and act only on pending\u2192paid.\n const res = await fetch(`${base}/v1/cms/items/orders/${itemId}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const order = ((await res.json()) as Item).data?.data ?? {};\n if (order.status !== 'paid') return Response.json({ skipped: true, status: order.status });\n\n // 1. receipt email (in-app inbox + email via the configured provider).\n if (notif && order.end_user) {\n await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${notif}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_id: order.end_user,\n template: 'transactional',\n data: { subject: `Receipt for order ${order.number}`, paragraph: `Thanks! Your order ${order.number} totalling ${order.total_cents} cents is confirmed.` },\n }),\n }).catch(() => { /* the jobs/webhooks retry+DLQ engine owns durability */ });\n }\n\n // 2. fulfillment webhook to the tenant's warehouse (egress-guarded to fulfillment.example.com).\n await fetch('https://fulfillment.example.com/orders', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ number: order.number, total_cents: order.total_cents }),\n }).catch(() => { /* best-effort here */ });\n\n return Response.json({ settled: order.number });\n },\n};\n",
|
|
9068
|
+
"on-order-paid.ts": "// on-order-paid.ts \u2014 SETTLEMENT SIDE-EFFECTS (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.updated for `orders`. When the order flips to\n// `paid` \u2014 YOUR payment-success handler PATCHes it (e.g. a function subscribed to the\n// payments `payments.charge.succeeded` event via a webhooks-out subscription on the\n// `payments.` prefix, or your backend after the hosted checkout returns); the\n// order_transition hook validates the flip \u2014 fan out the side-effects:\n// email the receipt (notifications) and POST the fulfillment webhook to the tenant's\n// 3PL/warehouse over the egress allowlist. Delivery is at-least-once with retry/DLQ \u2014\n// identical semantics to Shopify Flow / a Stripe webhook fan-out.\n//\n// The cms-hook payload is { event, collection, item_id } \u2014 NOT the row \u2014 so the\n// function RE-FETCHES the order by id (through the edge, tenant-scoped). notifications:send\n// is a legitimate function scope (allowed by the deploy; https://vxil.com/docs/guide/08-running-your-code-functions).\n//\n// (Inventory was already reserved atomically at checkout, so there is no decrement here \u2014\n// the reservation simply becomes permanent. A payment FAILURE path compensates instead.)\n\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string>; payload?: { collection?: string; item_id?: string } }\ninterface OrderData { number?: string; status?: string; total_cents?: number; end_user?: string }\ninterface Item { data?: { data?: OrderData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (functions.md \xA73); this guard stays as belt-and-braces.\n if (env.payload?.collection !== 'orders' || !cms || !itemId) return Response.json({ skipped: true });\n\n // Re-fetch the order (the payload carries only the id) and act only on pending\u2192paid.\n const res = await fetch(`${base}/v1/cms/items/orders/${itemId}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const order = ((await res.json()) as Item).data?.data ?? {};\n if (order.status !== 'paid') return Response.json({ skipped: true, status: order.status });\n\n // 1. receipt email (in-app inbox + email via the configured provider).\n if (notif && order.end_user) {\n await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${notif}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_id: order.end_user,\n template: 'transactional',\n data: { subject: `Receipt for order ${order.number}`, paragraph: `Thanks! Your order ${order.number} totalling ${order.total_cents} cents is confirmed.` },\n }),\n }).catch(() => { /* the jobs/webhooks retry+DLQ engine owns durability */ });\n }\n\n // 2. fulfillment webhook to the tenant's warehouse (egress-guarded to fulfillment.example.com).\n await fetch('https://fulfillment.example.com/orders', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ number: order.number, total_cents: order.total_cents }),\n }).catch(() => { /* best-effort here */ });\n\n return Response.json({ settled: order.number });\n },\n};\n",
|
|
9055
9069
|
"price-cart.ts": "// price-cart.ts \u2014 THE PRICING ENGINE (a vxil function, \xA77.3).\n//\n// This is the module people assume needs a \"promotions feature\". It does NOT \u2014 and it\n// deliberately is NOT a cms lifecycle hook: hooks are single-row and cross-row aggregation\n// is forbidden by design (hooks.ts), so a hook can't sum a cart, apply BOGO across items,\n// or evaluate cart-level thresholds. That is arbitrary domain logic \u2192 a FUNCTION with full\n// JS expressiveness (exactly how Shopify Functions / Scripts run tenant discount code) \u2192 [B].\n//\n// It reads the cart lines + coupon (cms:read) and returns the priced cart. Like checkout,\n// invoke it SERVER-SIDE: under cms.strictEndUserScope the shared collections it reads\n// (cart_items/coupons) are correctly denied to an end-user-mode invocation. checkout.ts\n// recomputes its total from the same server-held snapshots \u2014 never trust a client total;\n// to honor promotions at capture time, apply this function's output there the same way.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n // the caller's HTTP body rides the invocation envelope under `payload` (functions.md \xA72)\n payload?: { cart_id?: string; coupon_code?: string };\n}\ninterface Line { variant: string; qty: number; unit_price_cents: number }\ninterface Coupon { code: string; kind: string; value: number; max_uses: number }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const { cart_id, coupon_code } = env.payload ?? {};\n if (!cart_id) return Response.json({ error: 'cart_id required' }, { status: 400 });\n\n const lines = await get<Line>(`${base}/v1/cms/items/cart_items?filter=${enc({ cart: cart_id })}&limit=100`, cms);\n\n // subtotal (cross-row sum \u2014 the thing a hook can't do)\n const subtotal = lines.reduce((s, l) => s + l.unit_price_cents * l.qty, 0);\n\n // \u2500\u2500 arbitrary promotion rules, plain JS \u2500\u2500\n let discount = 0;\n const applied: string[] = [];\n\n // BOGO on any 2+ identical lines: cheapest unit free per pair\n for (const l of lines) {\n if (l.qty >= 2) { discount += Math.floor(l.qty / 2) * l.unit_price_cents; applied.push('bogo'); }\n }\n\n // tiered cart threshold: 5% over $100, 10% over $250\n if (subtotal >= 25000) { discount += Math.round(subtotal * 0.10); applied.push('tier-10'); }\n else if (subtotal >= 10000) { discount += Math.round(subtotal * 0.05); applied.push('tier-5'); }\n\n // coupon (percent or fixed) \u2014 stacks on top, capped so total never goes below 0\n if (coupon_code) {\n const [c] = await get<Coupon>(`${base}/v1/cms/items/coupons?filter=${enc({ code: coupon_code })}&limit=1`, cms);\n if (c) {\n discount += c.kind === 'percent' ? Math.round(subtotal * (c.value / 100)) : c.value;\n applied.push(`coupon:${c.code}`);\n }\n }\n\n const total = Math.max(0, subtotal - discount);\n return Response.json({ subtotal_cents: subtotal, discount_cents: subtotal - total, total_cents: total, applied });\n },\n};\n\nasync function get<T>(url: string, jwt: string): Promise<T[]> {\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: T }[] } };\n return (body.data?.items ?? []).map((i) => ({ id: i.item_id, ...i.data } as T));\n}\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n"
|
|
9056
9070
|
}
|
|
9057
9071
|
},
|
|
@@ -9332,7 +9346,7 @@ export default defineConfig({
|
|
|
9332
9346
|
`,
|
|
9333
9347
|
"readme": "# Helpdesk / Support Ticketing template\n\nA support-ticketing backend \u2014 requester-owned tickets with a hook-enforced status state machine,\nthreaded conversation messages, an acknowledgement send on every new ticket, and an hourly\nSLA-escalation cron \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**What it provisions:**\n- `tickets` \u2014 subject, `status` (state machine below), priority, `requester` (the **owner field**),\n `opened_at`, `sla_due`, body. Every queue-driving field is slot-indexed for filter/sort.\n- `ticket_messages` \u2014 the conversation thread: `ticket` relation, author, body, `sent_at`.\n- Features: `cms` + `auth` (email/password end-users) + `notifications` (mock provider) + `functions`.\n- Functions: `on-ticket-created` (cmsHook \u2192 acknowledgement send) and `sla-sweep` (hourly cron).\n\n**Apply it:**\n\n```bash\nvxil init --template helpdesk\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **A status state machine in a Lane-A hook** \u2014 the `beforeUpdate` validate allows only\n `open\u2192pending|resolved`, `pending\u2192open|resolved`, `resolved\u2192closed`; any other transition is a\n clean 422, atomically, in the write itself (vxil.com/docs/guide/07-validation-and-hooks).\n- **SLA automation as a cron function** \u2014 `sla-sweep` queries breached tickets with the \xA73 filter DSL\n (`status $in` + `sla_due $lt`, slot-indexed) and PATCHes `priority: 'urgent'`; the `$ne: 'urgent'`\n term makes re-runs idempotent.\n- **Requester-scoped end-user access** \u2014 `tickets.ownerField = 'requester'`: a signed-in requester\n sees and edits only their **own** tickets (vxil.com/docs/guide/04-data-with-cms); server keys see the queue.\n\n```ts\nconst { item_id } = await vx.from('tickets').create({\n subject: 'Cannot sign in on mobile', status: 'open', priority: 'normal',\n requester: 'user_demo', opened_at: new Date().toISOString(),\n sla_due: new Date(Date.now() + 8 * 3600e3).toISOString(), body: 'Steps to reproduce\u2026',\n});\n\n// the agent queue, most-overdue first (slot-indexed \u2192 typed filter/sort, index-served)\nconst { items } = await vx.from('tickets').query({\n filter: { status: { $in: ['open', 'pending'] } }, sort: 'sla_due', limit: 25,\n});\n```\n\n**Go deeper:** vxil.com/docs/guide/04-data-with-cms (query DSL \xB7 owner-scoping) \xB7 vxil.com/docs/guide/07-validation-and-hooks \xB7\nvxil.com/docs/guide/08-running-your-code-functions \xB7 vxil.com/docs/guide/06-feature-catalog (notifications) \xB7 `examples/ecommerce/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
|
|
9334
9348
|
"functions": {
|
|
9335
|
-
"on-ticket-created.ts": "// on-ticket-created.ts \u2014 the ACKNOWLEDGEMENT hook (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `tickets`. On a CREATE, send the\n// requester an acknowledgement through notifications. The cms-hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// ticket by id (through the edge, tenant-scoped) rather than trusting inline fields.\n// Delivery is at-least-once: the envelope idempotency_key rides the send as its\n// Idempotency-Key header, so a redelivered hook never double-sends.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n idempotency_key?: string;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\ninterface TicketData { subject?: string; status?: string; requester?: string; sla_due?: string }\ninterface Item { data?: { data?: TicketData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n if (env.payload?.collection !== 'tickets' || !cms || !notif || !itemId) {\n return Response.json({ skipped: true });\n }\n // acknowledge only the CREATE (the cms.item.* subscription also delivers updates)\n if (!String(env.payload?.event ?? '').endsWith('.created')) {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n\n // Re-fetch the ticket (the payload carries only the id).\n const res = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const ticket = ((await res.json()) as Item).data?.data ?? {};\n if (!ticket.requester) return Response.json({ skipped: true, reason: 'no requester' });\n\n // Acknowledge to the requester (email/inbox via the configured provider).\n const send = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n ...(env.idempotency_key ? { 'idempotency-key': env.idempotency_key } : {}),\n },\n body: JSON.stringify({\n user_id: ticket.requester,\n template: 'transactional',\n data: {\n subject: `We got your ticket: ${ticket.subject ?? itemId}`,\n paragraph:\n `Your ticket is ${ticket.status ?? 'open'} and in our queue` +\n `${ticket.sla_due ? ` (response due by ${ticket.sla_due})` : ''}. ` +\n 'Reply in the app to add details.',\n },\n }),\n });\n return Response.json({ acknowledged: itemId, delivery: send.status });\n },\n};\n",
|
|
9349
|
+
"on-ticket-created.ts": "// on-ticket-created.ts \u2014 the ACKNOWLEDGEMENT hook (a vxil function, \xA77.3).\n//\n// Trigger: cmsHook \u2014 fires on cms.item.* for `tickets`. On a CREATE, send the\n// requester an acknowledgement through notifications. The cms-hook payload is\n// { event, collection, item_id } \u2014 NOT the row \u2014 so the function RE-FETCHES the\n// ticket by id (through the edge, tenant-scoped) rather than trusting inline fields.\n// Delivery is at-least-once: the envelope idempotency_key rides the send as its\n// Idempotency-Key header, so a redelivered hook never double-sends.\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n idempotency_key?: string;\n payload?: { event?: string; collection?: string; item_id?: string };\n}\ninterface TicketData { subject?: string; status?: string; requester?: string; sla_due?: string }\ninterface Item { data?: { data?: TicketData } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (functions.md \xA73); this guard stays as belt-and-braces.\n if (env.payload?.collection !== 'tickets' || !cms || !notif || !itemId) {\n return Response.json({ skipped: true });\n }\n // acknowledge only the CREATE (the cms.item.* subscription also delivers updates)\n if (!String(env.payload?.event ?? '').endsWith('.created')) {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n\n // Re-fetch the ticket (the payload carries only the id).\n const res = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const ticket = ((await res.json()) as Item).data?.data ?? {};\n if (!ticket.requester) return Response.json({ skipped: true, reason: 'no requester' });\n\n // Acknowledge to the requester (email/inbox via the configured provider).\n const send = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notif}`,\n 'content-type': 'application/json',\n ...(env.idempotency_key ? { 'idempotency-key': env.idempotency_key } : {}),\n },\n body: JSON.stringify({\n user_id: ticket.requester,\n template: 'transactional',\n data: {\n subject: `We got your ticket: ${ticket.subject ?? itemId}`,\n paragraph:\n `Your ticket is ${ticket.status ?? 'open'} and in our queue` +\n `${ticket.sla_due ? ` (response due by ${ticket.sla_due})` : ''}. ` +\n 'Reply in the app to add details.',\n },\n }),\n });\n return Response.json({ acknowledged: itemId, delivery: send.status });\n },\n};\n",
|
|
9336
9350
|
"sla-sweep.ts": "// sla-sweep.ts \u2014 SLA ESCALATION CRON (a vxil function, \xA77.3).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep tickets whose sla_due has passed and\n// that are still open/pending \u2014 the cms filter DSL (vxil.com/docs/guide/04-data-with-cms):\n// `status $in` on the s2 slot, `sla_due $lt` on the t2 slot, `priority $ne` on\n// s3 \u2014 all index-served. Each breach is escalated with a PATCH to priority\n// 'urgent'; the $ne term makes re-runs idempotent (an escalated ticket falls out\n// of the filter). The Lane-A state-machine hook still runs on every PATCH; a\n// priority-only write keeps item.status == before.status, so it always passes.\n\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string> }\ninterface Ticket { id: string; data: { subject?: string; priority?: string } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // breached = still open/pending, past its sla_due, not yet urgent\n const filter = enc({\n status: { $in: ['open', 'pending'] },\n sla_due: { $lt: new Date().toISOString() },\n priority: { $ne: 'urgent' },\n });\n const res = await fetch(`${base}/v1/cms/items/tickets?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ error: `query ${res.status}` }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: Ticket[] } };\n const breached = body.data?.items ?? [];\n\n let escalated = 0;\n for (const t of breached) {\n const r = await fetch(`${base}/v1/cms/items/tickets/${t.id}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { priority: 'urgent' } }),\n });\n if (r.ok) escalated++;\n }\n return Response.json({ scanned: breached.length, escalated });\n },\n};\n\n// \u2500\u2500 tiny helper (the vxil REST list envelope is { data: { items } }) \u2500\u2500\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n"
|
|
9337
9351
|
}
|
|
9338
9352
|
},
|
|
@@ -9379,7 +9393,7 @@ export default defineConfig({
|
|
|
9379
9393
|
"configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Team Workspace\" \u2014 the ENTERPRISE blueprint: many companies inside one\n// backend, each with its own members and roles, signing in through the\n// company's own identity provider, reading a document set where ONE field is\n// visible only to finance. Declared end-to-end in ONE typed file.\n//\n// \u2022 orgs \u2192 organizations + memberships + a tenant-defined `finance` role\n// \u2022 auth \u2192 email/password or magic link today, a generic OIDC issuer as a\n// config swap; account-security controls; a 3-device session cap\n// \u2022 cms \u2192 projects \u2192 documents, owner-scoped, `restrict`-protected, with\n// ONE per-record action button and ONE role-gated field\n// \u2022 functions \u2192 the single step the Archive button runs\n//\n// The one thing that is NOT in this file: inviting a teammate to the vxil\n// PROJECT itself (the dashboard's pending-email invite). That is an operator\n// flow, not app config \u2014 see the README.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n // \u2500\u2500 Workspaces for YOUR customers' teams \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // An organization is a customer company; a membership carries a role. The\n // built-in lattice is owner > admin > member > viewer; `finance` below is a\n // CUSTOM role you define once over the API (see the README) and then assign\n // like any built-in one.\n orgs: {\n enabled: true,\n maxMembersPerOrg: 200,\n invitationTtlHours: 72, // an org invitation token is single-use + TTL-bound\n },\n\n auth: {\n // The demo path: email+password (and magic link) so the walkthrough runs\n // with no identity provider at all.\n methods: { emailPassword: true, magicLink: true },\n\n // \u2500\u2500 SSO: the generic OIDC issuer, as a CONFIG SWAP \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Uncomment this block, store the client secret once, and every member of\n // the workspace signs in through the company's IdP instead. The endpoints\n // and signing keys are discovered from the issuer \u2014 nothing else changes\n // in this file, and no code changes at all. `clientId` is not a secret\n // (it rides every authorize URL); the secret stays a REFERENCE.\n //\n // providers: {\n // oidc: {\n // issuer: 'https://login.example-idp.com', // https, no query/fragment\n // clientId: 'vxil-team-workspace',\n // clientSecretRef: 'secret:oidc_client_secret', // the `secrets` block below\n // scopes: ['email', 'profile'], // `openid` is always added\n // claims: { email: 'email', name: 'name', roles: 'groups' },\n // allowedDomains: ['example.com'], // fail-closed domain fence\n // autoLink: true, // link to a matching verified email\n // },\n // },\n\n // Roles ride the SESSION. With this on, the member's active-org role is\n // embedded in the session at sign-in and refresh, so a read can be gated\n // on it without a round-trip. It is a SNAPSHOT (refreshed with the\n // session) \u2014 use the orgs permission check for revocation-grade calls.\n orgClaims: { enabled: true },\n\n // A member may hold at most three live sessions; a fourth sign-in takes\n // over the oldest (it is revoked, and the sign-in reports which).\n session: { ttlMinutes: 60, refreshTtlDays: 30, maxConcurrent: 3 },\n\n // \u2500\u2500 Account-security controls (all opt-in, all off by default) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n security: {\n // repeated bad passwords on one identifier \u2192 locked, with a retry hint\n lockout: { maxFailures: 5, windowMinutes: 15, lockMinutes: 15 },\n // refuse a sign-up / reset whose password appears in a breach corpus\n breachedPasswords: true,\n // EVERY caller-supplied return URL must match one of these exactly \u2014\n // the anti-open-redirect fence for magic links, resets and SSO returns.\n allowedRedirectOrigins: ['https://app.example.com'],\n // captchaSecretRef: 'turnstile_secret', // add to require a captcha token\n },\n },\n\n cms: {\n hooks: {\n // The DOCUMENT lifecycle, enforced atomically inside the same write.\n // `archived` is terminal; the Archive button below is just the last\n // legal transition, so the button and the API agree by construction.\n document_stage: {\n collection: 'documents',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.state == before.state'\n + \" || (before.state == 'draft' && (item.state == 'in_review' || item.state == 'archived'))\"\n + \" || (before.state == 'in_review' && (item.state == 'draft' || item.state == 'approved'))\"\n + \" || (before.state == 'approved' && item.state == 'archived')\",\n message: 'illegal document state transition',\n },\n },\n },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n projects: {\n singular: 'project',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n // one project code per workspace \u2014 a duplicate is a clean 409\n code: { type: 'string', unique: true, indexSlot: 's2' },\n stage: { type: 'string', indexSlot: 's3' }, // discovery | active | closed\n created_at: { type: 'datetime', indexSlot: 't1' },\n summary: { type: 'text' },\n },\n },\n\n documents: {\n singular: 'document',\n // Owner-scoping: a signed-in member reads/edits only their OWN\n // documents. A no-op for server callers \u2014 your own backend still sees\n // the whole set.\n ownerField: 'author',\n\n // ONE human-initiated step per record. The dashboard renders a button\n // on every row; pressing it invokes the named function ONCE with\n // { collection, item_id, action, actor, item }. No conditions, no\n // chaining, no scheduling \u2014 the moment it needs branches it is a\n // function of your own, not a button.\n actions: [{ key: 'archive', label: 'Archive', fn: 'archive-document' }],\n\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n author: { type: 'string', indexSlot: 's2' }, // the owner (end-user id)\n // `restrict`: while a live document points at a project, deleting\n // that project is REFUSED (409) instead of silently orphaning or\n // cascading. The reverse read (`\u2026/backlinks`) tells you who holds it.\n project: { type: 'relation', relationTo: 'projects', onDelete: 'restrict', indexSlot: 's3' },\n state: { type: 'string', indexSlot: 's4' }, // draft | in_review | approved | archived\n // \u2500\u2500 FIELD-LEVEL READ SECURITY \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Only a signed-in member whose session carries the `finance` role\n // ever receives this field. Everyone else gets the document WITHOUT\n // it \u2014 absent, not null \u2014 and cannot filter or sort on it either, so\n // it can never be read one bit at a time. Your own server key still\n // sees it: this gates END USERS, not you.\n budget_usd: { type: 'int', indexSlot: 'n1', readRoles: ['finance'] },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text' },\n },\n },\n },\n },\n\n // \u2500\u2500 The one step that isn't config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n functions: {\n // The Archive button. Invoked through the per-record action route with the\n // pressing member's verified principal carried whole, so the write it makes\n // is owner-scoped exactly as if the member had made it themselves.\n 'archive-document': {\n entry: './functions/archive-document.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // nothing external; it only talks back to your own backend\n },\n },\n\n // References only \u2014 values are stored once and never appear in this file.\n secrets: {\n oidc_client_secret: { feature: 'auth', description: 'OIDC client secret for the workspace identity provider' },\n },\n\n seed: {\n cms: [\n {\n collection: 'projects',\n items: [\n {\n name: 'Northwind Rollout',\n code: 'NW-2026',\n stage: 'active',\n created_at: '2026-01-06T09:00:00Z',\n summary: 'Migration of the Northwind account onto the new platform.',\n },\n ],\n },\n ],\n },\n});\n",
|
|
9380
9394
|
"readme": '# Team Workspace (saas)\n\nThe **B2B** blueprint: many customer companies inside one backend, each with its own members\nand roles, signing in through the company\'s own identity provider \u2014 and a document set where\none field is visible only to finance.\n\nFive things most "add multi-tenancy to my SaaS" projects end up building by hand, declared here\ninstead: **organizations**, **roles that ride the session**, **SSO as a config swap**,\n**field-level read security**, and **one button per record**.\n\n```bash\nvxil init --template team-workspace\nvxil quickstart # or `vxil link <slug>` for an existing backend\nvxil push # collections + hooks + the archive function\n```\n\n## The five things, and where each one lives\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| Customer companies + memberships | `features.orgs` | `POST /v1/orgs`, members, invitations, a permission check \u2014 no membership table of your own |\n| A `finance` role | **not config** \u2014 `POST /v1/orgs/roles` | roles are rows, so you add one without a deploy |\n| Roles on the session | `features.auth.orgClaims.enabled` | the member\'s active-org role rides the session token; a read can be gated on it with no round-trip |\n| SSO | `features.auth.providers.oidc` (commented) | one block swaps email+password for the customer\'s identity provider |\n| Lockout / breach / redirect fence | `features.auth.security` | the account-security controls, all opt-in, all off until you ask |\n| A 3-device cap | `features.auth.session.maxConcurrent` | a fourth sign-in takes over the oldest session and tells you which |\n| Hiding `budget_usd` | `readRoles: [\'finance\']` on the field | the field is **absent** for everyone else \u2014 and unfilterable, so it cannot be read one bit at a time |\n| Refusing an orphaning delete | `onDelete: \'restrict\'` on the relation | deleting a project that still holds documents is a clean 409, not a cascade you did not ask for |\n| The Archive button | `actions: [{ key, label, fn }]` | one human-initiated step, one function, no workflow engine |\n\n## SSO \u2014 the config swap\n\nThe blueprint ships with email + password so the walkthrough runs with no identity provider.\nTo move a workspace onto its company\'s IdP, uncomment the `providers.oidc` block in\n`vxil.config.ts`, fill in three values, store one secret, and push:\n\n```ts\nproviders: {\n oidc: {\n issuer: \'https://login.example-idp.com\', // https, no query or fragment\n clientId: \'vxil-team-workspace\', // not a secret \u2014 it rides every authorize URL\n clientSecretRef: \'secret:oidc_client_secret\', // a REFERENCE; the value never enters this file\n scopes: [\'email\', \'profile\'], // `openid` is always added\n claims: { email: \'email\', name: \'name\', roles: \'groups\' },\n allowedDomains: [\'example.com\'], // fail-closed: an unlisted domain is refused\n autoLink: true, // link to an existing verified email\n },\n},\n```\n\n```bash\nprintf \'%s\' "$OIDC_SECRET" | vxil secrets set auth/oidc_client_secret\nvxil push\n```\n\nThen send people to `GET /v1/auth/oauth/oidc/start?redirect_uri=https://app.example.com/callback`.\nThe authorize endpoint, token endpoint and signing keys are **discovered from the issuer** \u2014 there\nis nothing else to configure and no code change at all. The presence of the block is the opt-in;\nthere is no separate toggle.\n\nTwo claims feed the session\'s role list: the member\'s **active-org role** (from `orgClaims`) and\nwhatever claim you name in `claims.roles` (from the IdP). Either one alone is enough to satisfy\n`readRoles: [\'finance\']`, which is why the same config works before and after SSO.\n\nAny broker that speaks OIDC \u2014 Okta, Entra, Auth0, WorkOS \u2014 puts a SAML customer behind this same\nblock. There is deliberately no separate SAML surface to learn.\n\n## Inviting people: two different invitations\n\nThey are easy to confuse, so name them apart:\n\n- **Your customers\' teammates** \u2192 `POST /v1/orgs/{org_id}/invitations` with `{ email, role }`,\n where `role` is `admin`, `member` or `viewer`. The single-use token comes back **once**, in that\n response \u2014 it is deliberately never emailed, so your app builds its own accept link and controls\n the wording. Accept with `POST /v1/orgs/invitations/accept`; list pending ones with\n `GET /v1/orgs/{org_id}/invitations`; revoke with `DELETE /v1/orgs/invitations/{invite_id}`.\n To land someone on a custom role such as `finance`, invite them as `member` and then\n `POST /v1/orgs/{org_id}/members` with the role. There is no resend \u2014 issue a new invitation and\n revoke the old one.\n- **Your own colleagues, on the vxil project itself** \u2192 the dashboard\'s **Members \u2192 Invite by\n email**. Type an address and it becomes a *pending* row with Resend and Revoke beside it; when\n they accept, they get a dashboard seat on this backend. That one is pure operator flow \u2014 no code,\n nothing in this config.\n\n## The 10-minute walkthrough\n\nEvery response below is the real shape. `$KEY` is a server key with `cms:read cms:write orgs:read\norgs:write auth:signin auth:write features:read`; `$API` is `https://api.vxil.com`.\n\n**1. Define the `finance` role.** Roles are rows, so this needs no deploy.\n\n```bash\nvxil api POST /v1/orgs/roles --data \'{"role_key":"finance","name":"Finance","permissions":["budgets.approve","reports.export"],"rank":2}\'\n# 201 { "data": { "role_key": "finance", "name": "Finance", "permissions": [...], "rank": 2, ... } }\n```\n\nA custom role is a named **permission set**, and the permission strings are yours \u2014 vxil never\ninterprets them, it only answers whether this member holds one. The four built-in roles\n(`owner > admin > member > viewer`) keep working alongside it.\n\n**2. Create a workspace and two members.**\n\n```bash\nvxil api POST /v1/orgs --data \'{"slug":"northwind","name":"Northwind","owner_user_id":"u_owner"}\'\n# 201 { "data": { "org_id": "org_\u2026", "slug": "northwind", "name": "Northwind", "created_at": "\u2026" } }\n```\n\nSign two people up, then seat them \u2014 one plain `member`, one on `finance`:\n\n```bash\nvxil api POST /v1/auth/sign-up --data \'{"email":"alice@example.com","password":"<a strong one>"}\'\nvxil api POST /v1/auth/sign-up --data \'{"email":"dana@example.com","password":"<a strong one>"}\'\nvxil api POST /v1/orgs/org_\u2026/members --data \'{"user_id":"<alice>","role":"member"}\'\nvxil api POST /v1/orgs/org_\u2026/members --data \'{"user_id":"<dana>","role":"finance"}\'\n# 201 { "data": { "org_id": "org_\u2026", "user_id": "\u2026", "role": "finance" } }\n```\n\nCheck what the session will carry:\n\n```bash\nvxil api GET "/v1/orgs/session-claims?user_id=<dana>"\n# 200 { "data": { "user_id": "\u2026", "org_id": "org_\u2026", "role": "finance", "perms": [...] } }\n```\n\n**3. Sign in \u2014 and watch the device cap.** Sign the same person in four times:\n\n```bash\ncurl -s -X POST "$API/v1/auth/sign-in" -H "authorization: Bearer $KEY" \\\n -H \'content-type: application/json\' \\\n -d \'{"email":"dana@example.com","password":"<a strong one>"}\'\n# 200 { "data": { "user_id": "\u2026",\n# "session": { "token": "\u2026", "refresh_token": "\u2026", "expires_at": "\u2026" },\n# "took_over": [ "sess_\u2026" ] } }\n```\n\nThe fourth sign-in reports the session it revoked in `took_over`. That array only appears because\n`session.maxConcurrent` is set \u2014 leave it out and responses are byte-identical to a backend that\nnever heard of the cap.\n\nRepeated wrong passwords stop being cheap after five: `429 account_locked` with a `Retry-After`\nheader, for fifteen minutes. Because the counter is keyed on a hash of the identifier, an unknown\naddress locks out exactly like a real one \u2014 no probing for which emails exist.\n\n**4. Write a document with a budget.** As the server key (no end-user session):\n\n```bash\nvxil api POST /v1/cms/items/projects --data \'{"data":{"name":"Northwind Rollout","code":"NW-2026","stage":"active"}}\'\nvxil api POST /v1/cms/items/documents --data \'{"data":{"title":"Statement of work","author":"<dana>","project":"<project item_id>","state":"draft","budget_usd":240000}}\'\n# 201 { "data": { "item_id": "itm_\u2026", "collection": "documents", "status": "draft",\n# "data": { "title": "\u2026", "budget_usd": 240000, \u2026 }, "version": 1, \u2026 } }\n```\n\nThe server key sees `budget_usd`. That is the point: the gate is for **end users**, not for you.\n\n**5. The gate, live.** Read the same document as a signed-in member, by sending the session\'s\n`token` in the `X-Vxil-End-User` header:\n\n```bash\n# dana \u2014 role `finance`\ncurl -s "$API/v1/cms/items/documents/<id>" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <dana\'s session token>"\n# 200 \u2026 "data": { "title": "Statement of work", "budget_usd": 240000, "state": "draft", \u2026 }\n\n# alice \u2014 role `member`\ncurl -s "$API/v1/cms/items/documents/<her own document\'s id>" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <alice\'s session token>"\n# 200 \u2026 "data": { "title": "\u2026", "state": "draft", \u2026 } \u2190 budget_usd is ABSENT\n```\n\nAbsent, not `null` \u2014 a `null` would itself be an answer. And it cannot be reached sideways either:\n\n```bash\ncurl -s "$API/v1/cms/items/documents?filter=%7B%22budget_usd%22%3A%7B%22%24gt%22%3A0%7D%7D" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <alice\'s session token>"\n# 422 { "error": { "code": "invalid_query", "message": "unknown field \'budget_usd\' \u2026" } }\n```\n\nTo a member without the role the field does not exist \u2014 not in the document, not in a filter, not\nin a sort, not through an expanded relation. Promote alice to `finance`, have her sign in again,\nand the field is simply there: the role travels on the session, so a new session is all it takes.\n\n**6. A delete that refuses.** The project still has a document pointing at it:\n\n```bash\nvxil api DELETE /v1/cms/items/projects/<project item_id>\n# 409 { "error": { "code": "referenced",\n# "message": "this item is still referenced by 1 live item(s) through an on_delete: \'restrict\' relation \u2026; nothing was deleted.",\n# "referencing": [ { "collection": "documents", "field": "project", "item_id": "itm_\u2026", "status": "draft" } ],\n# "has_more": false } }\n```\n\nAsk who is holding it, the same way the 409 did:\n\n```bash\nvxil api GET /v1/cms/items/projects/<project item_id>/backlinks\n# 200 { "data": { "collection": "projects", "item_id": "itm_\u2026",\n# "backlinks": [ { "collection": "documents", "field": "project", "item_id": "itm_\u2026", "status": "draft" } ],\n# "count": 1, "has_more": false, "limit": 25 } }\n```\n\n**7. The button.** One action, one function, one step:\n\n```bash\ncurl -s -X POST "$API/v1/cms/items/documents/<id>/actions/archive" \\\n -H "authorization: Bearer $KEY" -H "x-vxil-end-user: <dana\'s session token>"\n# 200 { "data": { "collection": "documents", "item_id": "itm_\u2026", "action": "archive",\n# "fn": "archive-document",\n# "result": { "archived": "itm_\u2026", "from": "draft", "state": "archived" } } }\n```\n\nThe function receives `{ collection, item_id, action, actor, item }` and runs with **the pressing\nmember\'s** verified identity, so its write is owner-scoped exactly as if they had made it. Press it\nagain and it answers `already: true` \u2014 a button a human can double-click needs to be idempotent.\n\nTry an illegal jump instead (`archived \u2192 draft`) and the collection\'s lifecycle hook rejects it\ninside the same write, so the button and the API can never disagree:\n\n```bash\nvxil api PATCH /v1/cms/items/documents/<id> --data \'{"data":{"state":"draft"}}\'\n# 422 \u2026 "illegal document state transition"\n```\n\n**8. Real authorization, when advisory is not enough.** The session role is a *snapshot*, refreshed\nwith the session. For anything that must reflect a revocation immediately, ask:\n\n```bash\nvxil api GET "/v1/orgs/org_\u2026/check?user_id=<dana>&permission=budgets.approve"\n# 200 { "data": { "org_id": "org_\u2026", "user_id": "\u2026", "permission": "budgets.approve",\n# "role": "finance", "allowed": true, "source": "role" } }\n```\n\n## What to learn from this\n\n- **Roles are data; the gate is config.** `finance` is a row you can create at 4pm on a Friday.\n `readRoles: [\'finance\']` is one field attribute. Neither is a code path you maintain.\n- **Field-level security has to be fail-safe in every direction, or it is theatre.** A gated field\n is removed from the document, from filters, from sorts, from expanded relations, and it is never\n served on a public read lane. The only caller that still sees it is your own backend.\n- **Owner-scoping and role-gating answer different questions.** `ownerField` decides *which rows*\n a member can see. `readRoles` decides *which fields* inside a row they get. You usually want both.\n- **`restrict` beats a cascade you did not think about.** Refusing the delete and naming the\n holders turns a data-loss bug into a 409 your UI can explain.\n- **An action is one step, on purpose.** The moment a button needs conditions or a second step, it\n is a function of yours, not a config entry \u2014 and that boundary is what keeps this from becoming a\n workflow engine.\n\n**Pairs with:** `templates/crm/` (the same relational spine without the org layer) and\n`templates/helpdesk/` (owner-scoped records with a state machine).\n',
|
|
9381
9395
|
"functions": {
|
|
9382
|
-
"archive-document.ts": "// archive-document.ts \u2014 the ARCHIVE BUTTON (a vxil function).\n//\n// Trigger: the per-record action `archive` declared on the `documents`\n// collection. Pressing the button POSTs\n// /v1/cms/items/documents/<id>/actions/archive\n// and the platform invokes THIS function once with the action envelope as its\n// payload:\n// { collection, item_id, action, actor, item: { item_id, status, version, data } }\n//\n// The pressing member's verified principal is carried into the scoped token, so\n// the PATCH below is owner-scoped exactly as if the member had written it \u2014 a\n// member can archive their own document and nobody else's, with no check here.\n//\n// It writes ONE transition (\u2192 'archived'). The collection's lifecycle hook is\n// still the authority: an illegal transition is rejected in the same write, and\n// this function reports that rejection instead of pretending it succeeded.\n\ninterface ActionPayload {\n collection?: string;\n item_id?: string;\n action?: string;\n actor?: { principal?: string; end_user_id?: string };\n item?: { status?: string; version?: number; data?: Record<string, unknown> };\n}\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: ActionPayload;\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const itemId = env.payload?.item_id;\n if (!cms || !itemId || env.payload?.collection !== 'documents') {\n return Response.json({ skipped: true, reason: 'not a documents action' });\n }\n\n // Already archived \u2192 nothing to do. The action is human-initiated and can be\n // pressed twice; make the second press a no-op rather than an error.\n const was = String(env.payload?.item?.data?.state ?? '');\n if (was === 'archived') {\n return Response.json({ archived: itemId, already: true, state: 'archived' });\n }\n\n const res = await fetch(`${base}/v1/cms/items/documents/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: { state: 'archived', updated_at: new Date().toISOString() },\n }),\n });\n\n if (!res.ok) {\n // The lifecycle hook refuses an illegal transition in-transaction (422).\n // Surface the real reason \u2014 the action route relays this class straight\n // back to the caller as `action_failed`.\n const detail = await res.text();\n return Response.json(\n { error: 'archive_refused', from: was, status: res.status, detail: detail.slice(0, 300) },\n { status: res.status === 422 ? 422 : 502 },\n );\n }\n\n return Response.json({ archived: itemId, from: was || 'draft', state: 'archived' });\n },\n};\n"
|
|
9396
|
+
"archive-document.ts": "// archive-document.ts \u2014 the ARCHIVE BUTTON (a vxil function).\n//\n// Trigger: the per-record action `archive` declared on the `documents`\n// collection. Pressing the button POSTs\n// /v1/cms/items/documents/<id>/actions/archive\n// and the platform invokes THIS function once with the action envelope as its\n// payload:\n// { collection, item_id, action, actor, item: { item_id, status, version, data } }\n//\n// The pressing member's verified principal is carried into the scoped token, so\n// the PATCH below is owner-scoped exactly as if the member had written it \u2014 a\n// member can archive their own document and nobody else's, with no check here.\n//\n// It writes ONE transition (\u2192 'archived'). The collection's lifecycle hook is\n// still the authority: an illegal transition is rejected in the same write, and\n// this function reports that rejection instead of pretending it succeeded.\n\ninterface ActionPayload {\n collection?: string;\n item_id?: string;\n action?: string;\n actor?: { principal?: string; end_user_id?: string };\n item?: { status?: string; version?: number; data?: Record<string, unknown> };\n}\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n payload?: ActionPayload;\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (functions.md \xA73); this guard stays as belt-and-braces.\n if (!cms || !itemId || env.payload?.collection !== 'documents') {\n return Response.json({ skipped: true, reason: 'not a documents action' });\n }\n\n // Already archived \u2192 nothing to do. The action is human-initiated and can be\n // pressed twice; make the second press a no-op rather than an error.\n const was = String(env.payload?.item?.data?.state ?? '');\n if (was === 'archived') {\n return Response.json({ archived: itemId, already: true, state: 'archived' });\n }\n\n const res = await fetch(`${base}/v1/cms/items/documents/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: { state: 'archived', updated_at: new Date().toISOString() },\n }),\n });\n\n if (!res.ok) {\n // The lifecycle hook refuses an illegal transition in-transaction (422).\n // Surface the real reason \u2014 the action route relays this class straight\n // back to the caller as `action_failed`.\n const detail = await res.text();\n return Response.json(\n { error: 'archive_refused', from: was, status: res.status, detail: detail.slice(0, 300) },\n { status: res.status === 422 ? 422 : 502 },\n );\n }\n\n return Response.json({ archived: itemId, from: was || 'draft', state: 'archived' });\n },\n};\n"
|
|
9383
9397
|
}
|
|
9384
9398
|
},
|
|
9385
9399
|
{
|
|
@@ -10377,7 +10391,7 @@ var catalogSql = {
|
|
|
10377
10391
|
ORDER BY cl.relname, c.conname`,
|
|
10378
10392
|
params: [schemas]
|
|
10379
10393
|
}),
|
|
10380
|
-
/**
|
|
10394
|
+
/** Source row-level access rules verbatim (R15 residual; R6 owner-column grep source). */
|
|
10381
10395
|
rlsPolicies: (schemas) => ({
|
|
10382
10396
|
text: `
|
|
10383
10397
|
SELECT schemaname AS table_schema, tablename AS table_name,
|
|
@@ -11278,6 +11292,9 @@ ${r.feature} (remote v${r.version}):`);
|
|
|
11278
11292
|
if (apply && r.cmsHookSubscriptions && (r.cmsHookSubscriptions.created || r.cmsHookSubscriptions.deleted)) {
|
|
11279
11293
|
console.log(` \u2713 cms-hook subscription reconciled (+${r.cmsHookSubscriptions.created} / -${r.cmsHookSubscriptions.deleted})`);
|
|
11280
11294
|
}
|
|
11295
|
+
if (apply && r.webhookSubscriptions && (r.webhookSubscriptions.created || r.webhookSubscriptions.deleted)) {
|
|
11296
|
+
console.log(` \u2713 webhook-trigger subscription reconciled (+${r.webhookSubscriptions.created} / -${r.webhookSubscriptions.deleted})`);
|
|
11297
|
+
}
|
|
11281
11298
|
for (const c of r.changes) if (c.kind !== "unchanged") count(`function:${c.name}`);
|
|
11282
11299
|
compared.functions += Object.keys(cfg.functions).length;
|
|
11283
11300
|
});
|
|
@@ -11541,6 +11558,7 @@ async function runGen(sel = "prod", opts = {}) {
|
|
|
11541
11558
|
}
|
|
11542
11559
|
async function runWatch(sel) {
|
|
11543
11560
|
const cwd = process.cwd();
|
|
11561
|
+
await promotionGate(resolveTargetOrFail(sel), false);
|
|
11544
11562
|
console.log("vxil dev --watch: applying on save (Ctrl-C to stop)\u2026");
|
|
11545
11563
|
const apply = async () => {
|
|
11546
11564
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vxil/cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The vxil CLI — init, quickstart, push, gen, secrets, migrate, doctor, diff, functions, payments; installs the `vxil` command (npm i -g @vxil/cli).",
|
|
6
6
|
"license": "MIT",
|