@vxil/cli 0.25.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/vxil-main.js CHANGED
@@ -3323,7 +3323,16 @@ function planSearchCollections(declared, live) {
3323
3323
  plan.create.push({
3324
3324
  collection: d.collection,
3325
3325
  ...d.dimensions !== void 0 ? { dimensions: d.dimensions } : {},
3326
- ...d.embed && Object.keys(d.embed).length ? { embed: { ...d.embed } } : {}
3326
+ ...d.embed && Object.keys(d.embed).length ? { embed: { ...d.embed } } : {},
3327
+ ...d.keywordNormalize !== void 0 ? { keywordNormalize: d.keywordNormalize } : {},
3328
+ ...d.orgScoped === true ? { org_scoped: true } : {}
3329
+ });
3330
+ continue;
3331
+ }
3332
+ if (d.orgScoped !== void 0 && d.orgScoped !== (l.org_scoped === true)) {
3333
+ plan.refused.push({
3334
+ collection: d.collection,
3335
+ error: `422 org_scoped_immutable: declared orgScoped ${d.orgScoped} but the live collection has ${l.org_scoped === true} \u2014 orgScoped is immutable; declare a new collection name and re-ingest`
3327
3336
  });
3328
3337
  continue;
3329
3338
  }
@@ -3337,10 +3346,19 @@ function planSearchCollections(declared, live) {
3337
3346
  if (providerChanged) changed.push("provider");
3338
3347
  if (e.model !== void 0 && l.model !== void 0 && e.model !== (l.model ?? null)) changed.push("model");
3339
3348
  if (e.apiKeyRef !== void 0 && l.api_key_ref !== void 0 && e.apiKeyRef !== (l.api_key_ref ?? null)) changed.push("apiKeyRef");
3349
+ if (d.keywordNormalize !== void 0 && l.keyword_normalize !== void 0 && d.keywordNormalize !== (l.keyword_normalize ?? "none")) changed.push("keywordNormalize");
3340
3350
  if (!changed.length) {
3341
3351
  plan.unchanged.push(d.collection);
3342
3352
  continue;
3343
3353
  }
3354
+ if (changed.length === 1 && changed[0] === "keywordNormalize") {
3355
+ plan.update.push({
3356
+ body: { collection: d.collection, dimensions: l.dimensions, keywordNormalize: d.keywordNormalize },
3357
+ changed,
3358
+ reembed: false
3359
+ });
3360
+ continue;
3361
+ }
3344
3362
  let embed;
3345
3363
  if (providerChanged) {
3346
3364
  const missing = KEYED_PROVIDERS.has(e.provider) ? ["model", "apiKeyRef"].filter((k) => e[k] === void 0) : [];
@@ -3359,7 +3377,16 @@ function planSearchCollections(declared, live) {
3359
3377
  embed = { ...provider ? { provider } : {}, ...model ? { model } : {}, ...apiKeyRef ? { apiKeyRef } : {} };
3360
3378
  }
3361
3379
  const reembed = (changed.includes("provider") || changed.includes("model")) && (l.documents ?? 1) > 0;
3362
- plan.update.push({ body: { collection: d.collection, dimensions: l.dimensions, embed }, changed, reembed });
3380
+ plan.update.push({
3381
+ body: {
3382
+ collection: d.collection,
3383
+ dimensions: l.dimensions,
3384
+ embed,
3385
+ ...d.keywordNormalize !== void 0 ? { keywordNormalize: d.keywordNormalize } : {}
3386
+ },
3387
+ changed,
3388
+ reembed
3389
+ });
3363
3390
  }
3364
3391
  for (const l of live) if (!declaredNames.has(l.collection)) plan.undeclared.push(l.collection);
3365
3392
  return plan;
@@ -3381,7 +3408,9 @@ function pulledSearchCollections(rows) {
3381
3408
  collection: String(r.collection),
3382
3409
  // the server only ever stores an allowed width
3383
3410
  ...typeof r.dimensions === "number" ? { dimensions: r.dimensions } : {},
3384
- ...Object.keys(embed).length ? { embed } : {}
3411
+ ...Object.keys(embed).length ? { embed } : {},
3412
+ ...r.keyword_normalize === "arabic" ? { keywordNormalize: "arabic" } : {},
3413
+ ...r.org_scoped === true ? { orgScoped: true } : {}
3385
3414
  };
3386
3415
  })
3387
3416
  };
@@ -3437,6 +3466,7 @@ function formatApiStateDetail(s) {
3437
3466
  for (const k of s.pending_destructive) lines.push(` ! ${k} \u2014 needs delete+recreate (new id); re-run with --allow-destructive`);
3438
3467
  for (const k of s.ambiguous) lines.push(` ! ${k} \u2014 more than one live row carries this name; delete the extras first`);
3439
3468
  for (const f of s.failed) lines.push(` \u2717 ${f.key} \u2014 ${f.error}`);
3469
+ for (const n of s.notes ?? []) lines.push(` i ${n.key} \u2014 ${n.note}`);
3440
3470
  return lines;
3441
3471
  }
3442
3472
 
@@ -4028,8 +4058,10 @@ var AuthConfigSchema = Type.Object({
4028
4058
  // fail-OPEN on network error → 422 password_breached.
4029
4059
  // • captchaSecretRef — a secret-store ref (feature 'auth') holding the
4030
4060
  // Turnstile secret; when set, sign-up / OTP request / magic-link request /
4031
- // email-verification request / password-reset request require
4032
- // `captcha_token` (403 captcha_failed otherwise).
4061
+ // email-verification request require `captcha_token` (403 captcha_failed
4062
+ // otherwise). The password-reset request joins them only with
4063
+ // captchaOnReset: true (opt-in, because live apps and SDKs ≤ 0.24 send
4064
+ // no token on reset and would lose password reset).
4033
4065
  // • allowedRedirectOrigins — when non-empty, EVERY caller-supplied
4034
4066
  // redirect/return URL (magic link, password reset, email verification,
4035
4067
  // OAuth start) must match one origin exactly → 422 redirect_not_allowed.
@@ -4044,6 +4076,9 @@ var AuthConfigSchema = Type.Object({
4044
4076
  })),
4045
4077
  breachedPasswords: Type.Boolean({ default: false }),
4046
4078
  captchaSecretRef: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
4079
+ // Optional, NO default (absent = false): every existing manifest folds
4080
+ // byte-identically and reset stays ungated. Inert without captchaSecretRef.
4081
+ captchaOnReset: Type.Optional(Type.Boolean()),
4047
4082
  allowedRedirectOrigins: Type.Optional(Type.Array(
4048
4083
  Type.String({ minLength: 8, maxLength: 253, pattern: REDIRECT_ORIGIN_PATTERN }),
4049
4084
  { maxItems: 32 }
@@ -4167,7 +4202,24 @@ var FilesConfigSchema = Type.Object({
4167
4202
  // an object to the cache-forever public host (POST /v1/files/{id}/publish).
4168
4203
  // ONE optional bag: { enabled, corsOrigins, variants }. The published-bytes
4169
4204
  // ceiling is a PLAN line (control-plane plans.ts), never a tenant knob.
4170
- publicAssets: Type.Optional(PublicAssetsConfigSchema)
4205
+ publicAssets: Type.Optional(PublicAssetsConfigSchema),
4206
+ // Workspace files (2026-10-12, guide ch. 6 files):
4207
+ // the active-org role gates on WORKSPACE files (objects with an org_id), ONE
4208
+ // optional bag. readRoles: the org roles that may list/read/mint download
4209
+ // URLs — UNSET = any member of the active org. writeRoles: the org roles
4210
+ // that may delete / set TTL / mint shared links / OCR — UNSET = ['owner',
4211
+ // 'admin']. The built-in `owner` and the object's uploader always pass.
4212
+ // Personal files and server keys are never affected.
4213
+ orgScope: Type.Optional(Type.Object({
4214
+ readRoles: Type.Optional(Type.Array(
4215
+ Type.String({ pattern: "^[a-z0-9][a-z0-9_-]{0,31}$" }),
4216
+ { minItems: 1, maxItems: 16 }
4217
+ )),
4218
+ writeRoles: Type.Optional(Type.Array(
4219
+ Type.String({ pattern: "^[a-z0-9][a-z0-9_-]{0,31}$" }),
4220
+ { minItems: 1, maxItems: 16 }
4221
+ ))
4222
+ }))
4171
4223
  });
4172
4224
  var DeclaredWebhookSubscriptionSchema = Type.Object({
4173
4225
  target_url: Type.String({ minLength: 9, maxLength: 2e3 }),
@@ -4505,7 +4557,13 @@ var DeclaredSearchCollectionSchema = Type.Object({
4505
4557
  ])),
4506
4558
  model: Type.Optional(Type.String({ maxLength: 128 })),
4507
4559
  apiKeyRef: Type.Optional(Type.String({ maxLength: 128 }))
4508
- }))
4560
+ })),
4561
+ // the keyword lane's opt-in Arabic spelling fold;
4562
+ // turning it on for a collection that holds documents needs a re-ingest.
4563
+ keywordNormalize: Type.Optional(Type.Union([Type.Literal("arabic"), Type.Literal("none")])),
4564
+ // WORKSPACE-ROWS Phase 2: documents carry the org id, end-user reads are
4565
+ // filtered to the active org; immutable after create (inside the array leaf).
4566
+ orgScoped: Type.Optional(Type.Boolean())
4509
4567
  });
4510
4568
  var VectorSearchConfigSchema = Type.Object({
4511
4569
  enabled: Type.Boolean({ default: true }),
@@ -5320,6 +5378,12 @@ function workspaceCollectionErrors(name, def) {
5320
5378
  }
5321
5379
  return errs;
5322
5380
  }
5381
+ var isOrgTemplateChannel = (ch) => typeof ch === "string" && ch.split(":").includes("{org}");
5382
+ var orgReadRolesOfDef = (def) => isObj(def) ? def.orgReadRoles ?? def.org_read_roles : void 0;
5383
+ var ownerFieldOfDef = (def) => {
5384
+ const v = isObj(def) ? def.ownerField ?? def.owner_field : void 0;
5385
+ return typeof v === "string" && v.length > 0;
5386
+ };
5323
5387
  var asList = (v) => Array.isArray(v) ? v : v === void 0 || v === null ? [] : [v];
5324
5388
  var stringsOf = (v) => asList(v).filter((s) => typeof s === "string");
5325
5389
  function workspaceManifestErrors(config, opts = {}) {
@@ -5331,24 +5395,48 @@ function workspaceManifestErrors(config, opts = {}) {
5331
5395
  const of = declaredOrgField(def);
5332
5396
  if (of) orgCols.set(name, of);
5333
5397
  }
5334
- if (orgCols.size === 0) return errs;
5335
5398
  const features = isObj(config.features) ? config.features : {};
5399
+ const cms = isObj(features.cms) ? features.cms : {};
5400
+ const cdcRules = Object.entries(isObj(cms.cdc) ? cms.cdc : {});
5401
+ for (const [rule, r] of cdcRules) {
5402
+ if (isObj(r) && typeof r.collection === "string" && isOrgTemplateChannel(r.channel) && Object.prototype.hasOwnProperty.call(collections, r.collection) && !orgCols.has(r.collection)) {
5403
+ errs.push(`features.cms.cdc.${rule}: a '{org}' channel needs an orgField collection \u2014 '${r.collection}' declares no orgField, so no row has a workspace to publish to`);
5404
+ }
5405
+ }
5406
+ const vs = isObj(features["vector-search"]) ? features["vector-search"] : {};
5407
+ const orgScopedTargets = new Set(asList(vs.collections).filter((c) => isObj(c) && (c.orgScoped === true || c.org_scoped === true) && typeof c.collection === "string").map((c) => c.collection));
5408
+ if (orgCols.size === 0 && orgScopedTargets.size === 0) return errs;
5336
5409
  const names = [...orgCols.keys()].join(", ");
5337
5410
  const auth = isObj(features.auth) ? features.auth : {};
5338
5411
  const oc = isObj(auth.orgClaims) ? auth.orgClaims : {};
5339
5412
  if (opts.checkAuth !== false && (oc.enabled !== true || oc.activeOrg !== true)) {
5340
- errs.push(`cms.collections ${names} set orgField, which needs features.auth.orgClaims { enabled: true, activeOrg: true } \u2014 without an active org the session carries no stable workspace`);
5413
+ if (orgCols.size) {
5414
+ errs.push(`cms.collections ${names} set orgField, which needs features.auth.orgClaims { enabled: true, activeOrg: true } \u2014 without an active org the session carries no stable workspace`);
5415
+ }
5416
+ if (orgScopedTargets.size) {
5417
+ errs.push(`vector-search.collections ${[...orgScopedTargets].join(", ")} set orgScoped, which needs features.auth.orgClaims { enabled: true, activeOrg: true } \u2014 without an active org no end user can read them`);
5418
+ }
5341
5419
  }
5342
- const cms = isObj(features.cms) ? features.cms : {};
5343
- for (const [rule, r] of Object.entries(isObj(cms.cdc) ? cms.cdc : {})) {
5344
- if (isObj(r) && typeof r.collection === "string" && orgCols.has(r.collection)) {
5345
- errs.push(`features.cms.cdc.${rule}: collection '${r.collection}' is workspace-scoped (orgField) \u2014 a cdc channel is not access control, so it is refused until org channels ship (Phase 2); fan out from a function with per-workspace channel names meanwhile`);
5420
+ for (const [rule, r] of cdcRules) {
5421
+ if (!isObj(r) || typeof r.collection !== "string" || !orgCols.has(r.collection)) continue;
5422
+ if (!isOrgTemplateChannel(r.channel)) {
5423
+ errs.push(`features.cms.cdc.${rule}: collection '${r.collection}' is workspace-scoped (orgField) \u2014 a fixed cdc channel is not access control; use a workspace channel template, e.g. channel: '${r.collection}:{org}' (each row publishes to org:<its org id>:${r.collection}, which only members of that workspace can subscribe to)`);
5424
+ } else if (r.payload === "full") {
5425
+ errs.push(`features.cms.cdc.${rule}: collection '${r.collection}' is workspace-scoped (orgField) \u2014 frames stay ids-only (payload: 'ids'); subscribers re-read through cms, where the workspace predicate applies`);
5426
+ } else if (stringsOf(orgReadRolesOfDef(collections[r.collection])).length > 0) {
5427
+ errs.push(`features.cms.cdc.${rule}: collection '${r.collection}' sets orgReadRoles \u2014 an org:<id> channel admits every member of the workspace, so its frames (item ids, status, timing) would reach roles that cannot read the collection; drop the rule or the orgReadRoles`);
5346
5428
  }
5347
5429
  }
5348
- const vs = isObj(features["vector-search"]) ? features["vector-search"] : {};
5349
5430
  asList(vs.sync).forEach((s, i) => {
5350
- if (isObj(s) && typeof s.cmsCollection === "string" && orgCols.has(s.cmsCollection)) {
5351
- errs.push(`features.vector-search.sync[${i}]: cmsCollection '${s.cmsCollection}' is workspace-scoped (orgField) \u2014 workspace-scoped sync arrives with orgScoped (Phase 2); search through a function meanwhile`);
5431
+ if (!isObj(s) || typeof s.cmsCollection !== "string") return;
5432
+ const target = typeof s.collection === "string" ? s.collection : "";
5433
+ if (orgCols.has(s.cmsCollection) && !orgScopedTargets.has(target)) {
5434
+ errs.push(`features.vector-search.sync[${i}]: cmsCollection '${s.cmsCollection}' is workspace-scoped (orgField) \u2014 the target '${target}' must be declared in vector-search.collections[] with orgScoped: true, or every workspace's rows become searchable by any end user`);
5435
+ } else if (orgCols.has(s.cmsCollection) && (stringsOf(orgReadRolesOfDef(collections[s.cmsCollection])).length > 0 || ownerFieldOfDef(collections[s.cmsCollection]))) {
5436
+ const which = stringsOf(orgReadRolesOfDef(collections[s.cmsCollection])).length > 0 ? "orgReadRoles" : "ownerField";
5437
+ errs.push(`features.vector-search.sync[${i}]: cmsCollection '${s.cmsCollection}' sets ${which} \u2014 synced documents are shared by every member of the workspace, so a member could search rows cms refuses them; drop the sync or the ${which}`);
5438
+ } else if (!orgCols.has(s.cmsCollection) && orgScopedTargets.has(target)) {
5439
+ errs.push(`features.vector-search.sync[${i}]: target '${target}' is orgScoped but cmsCollection '${s.cmsCollection}' is not workspace-scoped (orgField) \u2014 its documents would carry no workspace`);
5352
5440
  }
5353
5441
  });
5354
5442
  for (const [rmName, rm] of Object.entries(isObj(cms.readModels) ? cms.readModels : {})) {
@@ -5448,9 +5536,9 @@ function lowerAllTriggerBindings(def, name) {
5448
5536
  return bindings;
5449
5537
  }
5450
5538
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
5451
- var VXIL_CONFIG_PKG_VERSION = "0.18.0";
5452
- var VXIL_SDK_PKG_VERSION = "0.25.0";
5453
- var VXIL_CLI_PKG_VERSION = "0.25.0";
5539
+ var VXIL_CONFIG_PKG_VERSION = "0.19.0";
5540
+ var VXIL_SDK_PKG_VERSION = "0.26.0";
5541
+ var VXIL_CLI_PKG_VERSION = "0.26.1";
5454
5542
  function ensureScaffoldPackageJson(cwd, opts = {}) {
5455
5543
  const file = resolve(cwd, "package.json");
5456
5544
  const wanted = {
@@ -8698,6 +8786,7 @@ function summaryOf(datum, src) {
8698
8786
  if (Array.isArray(v)) summary[k].push(...v);
8699
8787
  }
8700
8788
  if (Array.isArray(src?.failed)) summary.failed.push(...src.failed);
8789
+ if (Array.isArray(src?.notes) && src.notes.length) summary.notes = [...src.notes];
8701
8790
  return summary;
8702
8791
  }
8703
8792
  function base(block, source) {
@@ -10812,6 +10901,16 @@ function redirectOriginNotice(cfg) {
10812
10901
  }
10813
10902
 
10814
10903
  // src/link.ts
10904
+ function linkEnvLabel(o) {
10905
+ if (o.envFlag !== void 0) return { env: o.envFlag, fromWorkload: false };
10906
+ const wl = o.workload === "production" ? "production" : o.workload === "staging" ? "staging" : o.workload === "development" ? "dev" : void 0;
10907
+ if (wl) {
10908
+ if (o.kept !== void 0 && o.kept === "production" === (wl === "production")) return { env: o.kept, fromWorkload: false };
10909
+ const notice = o.kept !== void 0 ? `relabelled ${o.kept} \u2192 ${wl} from the project's workload; \`--env ${o.kept}\` to keep it` : void 0;
10910
+ return { env: wl, fromWorkload: true, ...notice ? { notice } : {} };
10911
+ }
10912
+ return { env: o.kept ?? envLabelFor(o.base), fromWorkload: false };
10913
+ }
10815
10914
  function data(r) {
10816
10915
  return r.json.data ?? r.json;
10817
10916
  }
@@ -10845,7 +10944,7 @@ async function linkViaSession(dash, cookie, slug, keyName) {
10845
10944
  }
10846
10945
  const apiKey = data(mint).api_key;
10847
10946
  if (!apiKey) throw new Error("mint returned no api_key");
10848
- return { result: { slug, tenant_id: t.id, api_key: apiKey } };
10947
+ return { result: { slug, tenant_id: t.id, api_key: apiKey, ...typeof t.workload === "string" ? { workload: t.workload } : {} } };
10849
10948
  }
10850
10949
  async function linkViaDeviceFlow(dash, slug, io) {
10851
10950
  const now = io.now ?? Date.now;
@@ -10871,7 +10970,7 @@ async function linkViaDeviceFlow(dash, slug, io) {
10871
10970
  if (poll.status === 200) {
10872
10971
  const p = data(poll);
10873
10972
  if (!p.api_key || !p.tenant_id) throw new Error("approval returned no api_key");
10874
- return { slug: p.slug || slug, tenant_id: p.tenant_id, api_key: p.api_key };
10973
+ return { slug: p.slug || slug, tenant_id: p.tenant_id, api_key: p.api_key, ...typeof p.workload === "string" ? { workload: p.workload } : {} };
10875
10974
  }
10876
10975
  throw new Error(`link failed (${errText(poll)})`);
10877
10976
  }
@@ -11431,6 +11530,10 @@ function isWorkload(v) {
11431
11530
  function workloadEnvLabel(w) {
11432
11531
  return w === "production" ? "production" : w === "staging" ? "staging" : "dev";
11433
11532
  }
11533
+ function projectCreateNextHint(slug, workload) {
11534
+ const env = isWorkload(workload) ? ` --env ${workloadEnvLabel(workload)}` : "";
11535
+ return `next: vxil link ${slug} --mint${env} && vxil push (this repo's binding is unchanged until then)`;
11536
+ }
11434
11537
  function quickstartWorkload(workloadFlag, envFlag, dev) {
11435
11538
  if (workloadFlag !== void 0) {
11436
11539
  if (!isWorkload(workloadFlag)) return { error: `--workload must be one of ${PROJECT_WORKLOADS.join(", ")}` };
@@ -13660,7 +13763,7 @@ var TOOLS = [
13660
13763
  {
13661
13764
  name: "files_create_upload_url",
13662
13765
  feature: "files",
13663
- description: "Mint a presigned PUT URL for a new file (bytes go straight to storage, never through Vxil). After uploading, call files_complete to make the object available. Optional expiresInSeconds (60 s to 100 years) makes the object expire and be deleted that long after the mint; the answer's expires_at is when (null = never), expires_in is the upload URL's life in seconds. Optional checksum_sha256 is signed into the URL (the PUT must send the answer's upload_headers) and kept with the upload: files_complete answers 409 checksum_mismatch when the stored bytes hash differently, or 409 checksum_unverified when the store reports no checksum.",
13766
+ description: "Mint a presigned PUT URL for a new file (bytes go straight to storage, never through Vxil). After uploading, call files_complete to make the object available. Optional expiresInSeconds (60 s to 100 years) makes the object expire and be deleted that long after the mint; the answer's expires_at is when (null = never), expires_in is the upload URL's life in seconds. Optional checksum_sha256 is signed into the URL (the PUT must send the answer's upload_headers) and kept with the upload: files_complete answers 409 checksum_mismatch when the stored bytes hash differently, or 409 checksum_unverified when the store reports no checksum. Optional org_id makes it a WORKSPACE file (owned by that orgs org): members of the org can read it in their active org, it survives the uploader's erasure, and deleting the org removes it.",
13664
13767
  inputSchema: {
13665
13768
  type: "object",
13666
13769
  properties: {
@@ -13669,7 +13772,8 @@ var TOOLS = [
13669
13772
  content_type: { type: "string" },
13670
13773
  size_bytes: { type: "number" },
13671
13774
  expiresInSeconds: { type: ["integer", "null"], minimum: 60, maximum: 31536e5, description: "Delete the object this many seconds after the mint, 60..3153600000 (100 years) (null = never; omitted = the files.ttl default when enabled, else never)." },
13672
- checksum_sha256: { type: "string", minLength: 44, maxLength: 64, description: "SHA-256 of the bytes: 64 hex or 44 base64 characters. Signed into upload_url as x-amz-checksum-sha256 (returned in upload_headers)." }
13775
+ checksum_sha256: { type: "string", minLength: 44, maxLength: 64, description: "SHA-256 of the bytes: 64 hex or 44 base64 characters. Signed into upload_url as x-amz-checksum-sha256 (returned in upload_headers)." },
13776
+ org_id: { type: "string", maxLength: 128, description: "The owning workspace (an orgs org_id). Omit for a personal file." }
13673
13777
  },
13674
13778
  required: ["user_id", "filename", "content_type", "size_bytes"]
13675
13779
  },
@@ -13721,17 +13825,18 @@ var TOOLS = [
13721
13825
  {
13722
13826
  name: "files_list",
13723
13827
  feature: "files",
13724
- description: "List file objects (newest first; filter by user_id).",
13828
+ description: "List file objects (newest first; filter by user_id and/or org_id \u2014 a workspace's files).",
13725
13829
  inputSchema: {
13726
13830
  type: "object",
13727
13831
  properties: {
13728
13832
  user_id: { type: "string" },
13833
+ org_id: { type: "string", description: "Only this workspace's files (an orgs org_id)." },
13729
13834
  limit: { type: "number", default: 20 }
13730
13835
  }
13731
13836
  },
13732
13837
  method: "GET",
13733
13838
  path: "/v1/files",
13734
- queryArgs: ["user_id", "limit"]
13839
+ queryArgs: ["user_id", "org_id", "limit"]
13735
13840
  },
13736
13841
  {
13737
13842
  name: "files_delete",
@@ -14561,7 +14666,8 @@ var TOOLS = [
14561
14666
  top_k: { type: "number", minimum: 1, maximum: 100, default: 10 },
14562
14667
  mode: { type: "string", enum: ["hybrid", "vector", "keyword"], default: "hybrid" },
14563
14668
  filter: { type: "object", additionalProperties: true, description: 'Metadata filter, keys ANDed: equality {"user_id":"u_42"} or any-of {"folder":{"$in":["a","b"]}} (1\u201350 values, \u22644 $in keys). null / arrays / other operators are refused 422.' },
14564
- rerank: { type: "boolean", description: "Apply the configured BYO reranker (config rerank block) to the fused results before top-k. Fail-open: a provider fault returns the RRF order with reranked:false." }
14669
+ rerank: { type: "boolean", description: "Apply the configured BYO reranker (config rerank block) to the fused results before top-k. Fail-open: a provider fault returns the RRF order with reranked:false." },
14670
+ org_id: { type: "string", description: "Workspace-scoped (org_scoped) collections only: the org to search. Required with a server key (422 org_id_required without); an end-user session always searches its active workspace. Refused 422 on a collection that is not org-scoped." }
14565
14671
  },
14566
14672
  required: ["collection", "query"]
14567
14673
  },
@@ -14731,7 +14837,8 @@ var TOOLS = [
14731
14837
  model: { type: "string" },
14732
14838
  input: { type: "object", additionalProperties: true, description: "Extra template vars beyond {{query}}/{{context}}." },
14733
14839
  user_id: { type: "string", description: "Opaque end_user_id for per-user usage metering." },
14734
- min_similarity: { type: "number", minimum: -1, maximum: 1, description: "Ground only on chunks whose cosine similarity to the query is at least this (-1..1). Overrides rag config retrieval.minSimilarity." }
14840
+ min_similarity: { type: "number", minimum: -1, maximum: 1, description: "Ground only on chunks whose cosine similarity to the query is at least this (-1..1). Overrides rag config retrieval.minSimilarity." },
14841
+ org_id: { type: "string", description: "Workspace-scoped (org_scoped) collection: the org to ground on, forwarded to vector-search (server key; an end-user session uses its active workspace)." }
14735
14842
  },
14736
14843
  required: ["query"]
14737
14844
  },
@@ -14753,7 +14860,8 @@ var TOOLS = [
14753
14860
  rerank: { type: "boolean", description: "Forward rerank:true to vector-search's BYO rerank pass (inert until configured there)." },
14754
14861
  boosts: { type: "object", additionalProperties: true, description: "Per-metadata-field boost specs: { field: { kind:'recency', halfLifeDays?, weight? } | { kind:'value', weights:{\u2026} } }." },
14755
14862
  min_score: { type: "number", description: "Drop hits below this EFFECTIVE (post-boost) score. score is a rank value (about 0.016-0.033 at the top), so a floor above ~0.033 drops every hit; use min_similarity for a relevance threshold." },
14756
- min_similarity: { type: "number", minimum: -1, maximum: 1, description: "Drop hits whose cosine similarity to the query is below this (-1..1), e.g. 0.5. Overrides rag config retrieval.minSimilarity; a hit with no measured similarity (keyword mode) is kept." }
14863
+ min_similarity: { type: "number", minimum: -1, maximum: 1, description: "Drop hits whose cosine similarity to the query is below this (-1..1), e.g. 0.5. Overrides rag config retrieval.minSimilarity; a hit with no measured similarity (keyword mode) is kept." },
14864
+ org_id: { type: "string", description: "Workspace-scoped (org_scoped) collection: the org to ground on, forwarded to vector-search (server key; an end-user session uses its active workspace)." }
14757
14865
  },
14758
14866
  required: ["query"]
14759
14867
  },
@@ -14763,34 +14871,36 @@ var TOOLS = [
14763
14871
  {
14764
14872
  name: "payments_check_entitlement",
14765
14873
  feature: "payments",
14766
- description: "Check whether an end-user holds a boolean entitlement (a feature flag from the tenant's tierMap, e.g. 'export', 'priority_support'). Returns granted plus the source (subscription/tier), the resolved tier, until (when the answer stops being good: the period end, or end + grace while past_due) and as_of. A non-2xx answer means UNKNOWN \u2014 never treat it as free. Use this to gate access before doing privileged work. Read-only \u2014 needs payments:read.",
14874
+ description: "Check whether an end-user \u2014 or a workspace (org_id) \u2014 holds a boolean entitlement (a feature flag from the tenant's tierMap, e.g. 'export', 'priority_support'). Returns granted plus the source (subscription/tier), the resolved tier, until (when the answer stops being good: the period end, or end + grace while past_due) and as_of. A non-2xx answer means UNKNOWN \u2014 never treat it as free. Use this to gate access before doing privileged work. Read-only \u2014 needs payments:read.",
14767
14875
  inputSchema: {
14768
14876
  type: "object",
14769
14877
  properties: {
14770
- user_id: { type: "string", description: "Opaque end_user_id (your user id)." },
14878
+ user_id: { type: "string", description: "Opaque end_user_id (your user id). Give this OR org_id." },
14879
+ org_id: { type: "string", description: "WORKSPACE BILLING: the workspace (orgs id) instead of a user \u2014 the subject a checkout tagged vxil_org_id folded onto. Give this OR user_id." },
14771
14880
  entitlement: { type: "string", description: "Entitlement key to check, e.g. 'export' or 'seats'." }
14772
14881
  },
14773
- required: ["user_id", "entitlement"]
14882
+ required: ["entitlement"]
14774
14883
  },
14775
14884
  method: "GET",
14776
14885
  path: "/v1/payments/entitlements/check",
14777
- queryArgs: ["user_id", "entitlement"]
14886
+ queryArgs: ["user_id", "org_id", "entitlement"]
14778
14887
  },
14779
14888
  {
14780
14889
  name: "payments_balance",
14781
14890
  feature: "payments",
14782
- description: "Read an end-user's credit balance for a credit type (e.g. 'scans', 'tokens'): balance, held (provisional/in-flight), and available (= balance \u2212 held). Check available before consuming. Read-only \u2014 needs payments:read.",
14891
+ description: "Read an end-user's \u2014 or a workspace's (org_id) \u2014 credit balance for a credit type (e.g. 'scans', 'tokens'): balance, held (provisional/in-flight), and available (= balance \u2212 held). Check available before consuming. Read-only \u2014 needs payments:read.",
14783
14892
  inputSchema: {
14784
14893
  type: "object",
14785
14894
  properties: {
14786
- user_id: { type: "string", description: "Opaque end_user_id (your user id)." },
14895
+ user_id: { type: "string", description: "Opaque end_user_id (your user id). Give this OR org_id." },
14896
+ org_id: { type: "string", description: "WORKSPACE BILLING: the workspace (orgs id) instead of a user \u2014 the subject a checkout tagged vxil_org_id folded onto. Give this OR user_id." },
14787
14897
  credit_type: { type: "string", description: "Credit type to read, e.g. 'scans'." }
14788
14898
  },
14789
- required: ["user_id", "credit_type"]
14899
+ required: ["credit_type"]
14790
14900
  },
14791
14901
  method: "GET",
14792
14902
  path: "/v1/payments/credits/balance",
14793
- queryArgs: ["user_id", "credit_type"]
14903
+ queryArgs: ["user_id", "org_id", "credit_type"]
14794
14904
  },
14795
14905
  {
14796
14906
  name: "payments_consume",
@@ -14799,7 +14909,8 @@ var TOOLS = [
14799
14909
  inputSchema: {
14800
14910
  type: "object",
14801
14911
  properties: {
14802
- user_id: { type: "string", description: "Opaque end_user_id to debit." },
14912
+ user_id: { type: "string", description: "Opaque end_user_id to debit. Give this OR org_id." },
14913
+ org_id: { type: "string", description: "WORKSPACE BILLING: the workspace (orgs id) instead of a user \u2014 the subject a checkout tagged vxil_org_id folded onto. Give this OR user_id." },
14803
14914
  credit_type: { type: "string", description: "Credit type to debit, e.g. 'scans'. Give this OR credit_types." },
14804
14915
  credit_types: {
14805
14916
  type: "array",
@@ -14814,7 +14925,7 @@ var TOOLS = [
14814
14925
  reason: { type: "string", description: "Optional human-readable reason recorded on the ledger row." },
14815
14926
  allow_split: { type: "boolean", description: "With credit_types only: when no single type covers amount, draw across them in order (default false: 402)." }
14816
14927
  },
14817
- required: ["user_id", "amount", "idempotency_key"]
14928
+ required: ["amount", "idempotency_key"]
14818
14929
  },
14819
14930
  method: "POST",
14820
14931
  path: "/v1/payments/credits/consume",
@@ -15426,6 +15537,17 @@ function formatCompared(c) {
15426
15537
  }
15427
15538
  return parts.join(" \xB7 ");
15428
15539
  }
15540
+ function planFooter(counts, compared) {
15541
+ if (counts.drift > 0) return "(run `vxil push` to apply)";
15542
+ if (counts.ignored > 0 || counts.notCompared > 0) {
15543
+ const parts = [];
15544
+ if (counts.ignored > 0) parts.push(`${counts.ignored} ignored (\`vxil push\` would still apply them)`);
15545
+ if (counts.notCompared > 0) parts.push(`${counts.notCompared} not compared`);
15546
+ return `no unignored drift found \u2014 ${parts.join(" / ")}; not a clean match`;
15547
+ }
15548
+ const what = formatCompared(compared);
15549
+ return `\u2713 no drift \u2014 the target already matches your config${what ? ` (compared ${what})` : ""}; nothing to push`;
15550
+ }
15429
15551
 
15430
15552
  // src/doctor.ts
15431
15553
  var SECRETS_ADVISORY_NOTE = "advisory only: a config push with an unstored `secret:<name>` ref is a WARNING, never a 422 \u2014 vxil's promotion order sets production secrets AFTER the config push (`vxil secrets set <feature>/<name>`); doctor never deletes a stored secret";
@@ -16297,6 +16419,7 @@ async function reuseDevTenantBySlug(dash, cookie, slug) {
16297
16419
  }
16298
16420
 
16299
16421
  // src/try.ts
16422
+ var TRY_ENV_LABEL = "sandbox";
16300
16423
  async function mintTry(fetchImpl, base2, opts = {}) {
16301
16424
  const url = `${base2.replace(/\/$/, "")}/v1/try`;
16302
16425
  const body = opts.turnstileToken ? { turnstile_token: opts.turnstileToken } : {};
@@ -16598,6 +16721,9 @@ var EXTRA_KNOWN_FLAGS = [
16598
16721
  "keep-timestamps",
16599
16722
  "oidc-issuer",
16600
16723
  "only",
16724
+ "files-owner",
16725
+ "files-org-from-prefix",
16726
+ "files-org-map",
16601
16727
  "pg-schema",
16602
16728
  "profile-table",
16603
16729
  "prune",
@@ -18353,10 +18479,19 @@ function objectOwner(path, filesOwner) {
18353
18479
  const seg = path.split("/").find((s) => s.length > 0);
18354
18480
  return seg;
18355
18481
  }
18482
+ function objectOrg(path, orgFromPrefix, orgMap) {
18483
+ if (!orgFromPrefix) return void 0;
18484
+ const source = path.split("/").find((s) => s.length > 0);
18485
+ const mapped = source !== void 0 ? orgMap?.[source] : void 0;
18486
+ if (typeof mapped !== "string" || mapped.length === 0) {
18487
+ return { error: `files: object '${path}': workspace '${source ?? ""}' is not in --files-org-map \u2014 add "<source id>": "<vxil org id>" (GET /v1/orgs lists them)` };
18488
+ }
18489
+ return mapped;
18490
+ }
18356
18491
  var SHARED_LINK_ASK_MS = 100 * 365 * 24 * 3600 * 1e3;
18357
18492
  var SHARED_LINK_WARN_MS = 365 * 24 * 3600 * 1e3;
18358
18493
  var isUrl = (v) => typeof v === "string" && /^https?:\/\//i.test(v);
18359
- async function loadFiles({ deps, plan, adapter, state, save, filesOwner, fetchImpl, delta = false, fileLinks = "object_id" }) {
18494
+ async function loadFiles({ deps, plan, adapter, state, save, filesOwner, filesOrgFromPrefix = false, filesOrgMap, fetchImpl, delta = false, fileLinks = "object_id" }) {
18360
18495
  const results = [];
18361
18496
  if (plan.storageBuckets.length === 0) return results;
18362
18497
  if (!adapter.readObjects) {
@@ -18364,6 +18499,14 @@ async function loadFiles({ deps, plan, adapter, state, save, filesOwner, fetchIm
18364
18499
  return results;
18365
18500
  }
18366
18501
  const put = fetchImpl ?? fetch;
18502
+ if (filesOrgFromPrefix && !filesOwner) {
18503
+ deps.log("\u26A0 files: --files-org-from-prefix needs --files-owner <user id> (the path prefix is the workspace, not the owner) \u2014 storage objects NOT migrated");
18504
+ return results;
18505
+ }
18506
+ if (filesOrgFromPrefix && !filesOrgMap) {
18507
+ deps.log('\u26A0 files: --files-org-from-prefix needs --files-org-map <file.json> ({ "<source workspace id>": "<vxil org id>" } \u2014 vxil mints its own org ids) \u2014 storage objects NOT migrated');
18508
+ return results;
18509
+ }
18367
18510
  for (const bucket of plan.storageBuckets) {
18368
18511
  const key = `files:${bucket.name}`;
18369
18512
  const map2 = state.idMap[key] ??= {};
@@ -18376,7 +18519,7 @@ async function loadFiles({ deps, plan, adapter, state, save, filesOwner, fetchIm
18376
18519
  continue;
18377
18520
  }
18378
18521
  if (deps.dryRun) continue;
18379
- const err = await uploadOne(deps, put, obj, map2, filesOwner);
18522
+ const err = await uploadOne(deps, put, obj, map2, filesOwner, filesOrgFromPrefix, filesOrgMap);
18380
18523
  if (err) {
18381
18524
  result.errors.push(err);
18382
18525
  result.skipped++;
@@ -18521,8 +18664,10 @@ async function loadFiles({ deps, plan, adapter, state, save, filesOwner, fetchIm
18521
18664
  }
18522
18665
  return results;
18523
18666
  }
18524
- async function uploadOne(deps, put, obj, map2, filesOwner) {
18667
+ async function uploadOne(deps, put, obj, map2, filesOwner, orgFromPrefix = false, orgMap) {
18525
18668
  const owner = objectOwner(obj.path, filesOwner);
18669
+ const orgId = objectOrg(obj.path, orgFromPrefix, orgMap);
18670
+ if (typeof orgId === "object") return orgId.error;
18526
18671
  if (!owner) return `files: object '${obj.path}' has no derivable owner \u2014 pass filesOwner (--files-owner)`;
18527
18672
  let bytes;
18528
18673
  try {
@@ -18533,12 +18678,14 @@ async function uploadOne(deps, put, obj, map2, filesOwner) {
18533
18678
  const contentType = obj.contentType ?? "application/octet-stream";
18534
18679
  const created = await deps.api("POST", "/v1/files/upload-url", {
18535
18680
  user_id: owner,
18681
+ ...orgId ? { org_id: orgId } : {},
18536
18682
  filename: obj.path.split("/").pop() ?? obj.path,
18537
18683
  content_type: contentType,
18538
18684
  size_bytes: bytes.length
18539
18685
  });
18540
18686
  if (created.status !== 201 && created.status !== 200) {
18541
- const hint = created.body.error?.code === "user_not_found" ? " \u2014 run the users step first, or pass filesOwner if the path prefix is not the owner uid" : "";
18687
+ const code = created.body.error?.code;
18688
+ const hint = code === "user_not_found" ? " \u2014 run the users step first, or pass filesOwner if the path prefix is not the owner uid" : code === "org_not_found" ? " \u2014 --files-org-map must name a LIVE vxil org (GET /v1/orgs)" : "";
18542
18689
  return apiErrorLine(`files: upload-url for '${obj.path}' failed`, created) + hint;
18543
18690
  }
18544
18691
  const objectId = created.body.data?.object_id;
@@ -19307,6 +19454,8 @@ async function runData(deps, plan, adapter, opts = {}) {
19307
19454
  save: g.save,
19308
19455
  delta: delta !== void 0,
19309
19456
  ...opts.filesOwner !== void 0 ? { filesOwner: opts.filesOwner } : {},
19457
+ ...opts.filesOrgFromPrefix ? { filesOrgFromPrefix: true } : {},
19458
+ ...opts.filesOrgMap ? { filesOrgMap: opts.filesOrgMap } : {},
19310
19459
  ...opts.fileLinks ? { fileLinks: opts.fileLinks } : {},
19311
19460
  ...putFetch !== void 0 ? { fetchImpl: putFetch } : {}
19312
19461
  }));
@@ -19668,6 +19817,9 @@ async function runMigrateData(io, opts = {}) {
19668
19817
  ...specs ? { payments: specs } : {},
19669
19818
  ...credentials ? { credentials } : {},
19670
19819
  ...opts.fileLinks ? { fileLinks: opts.fileLinks } : {},
19820
+ ...opts.filesOwner !== void 0 ? { filesOwner: opts.filesOwner } : {},
19821
+ ...opts.filesOrgFromPrefix ? { filesOrgFromPrefix: true } : {},
19822
+ ...opts.filesOrgMap ? { filesOrgMap: opts.filesOrgMap } : {},
19671
19823
  ...opts.keepTimestamps ? { keepTimestamps: true } : {}
19672
19824
  });
19673
19825
  io.log(`${execute ? "\nmigrate data" : "\nmigrate data (DRY-RUN \u2014 zero writes)"}${since ? ` \u2014 incremental since ${since}${opts.prune ? " + prune" : ""}` : ""}:`);
@@ -20432,6 +20584,28 @@ export default defineConfig({
20432
20584
  "triage-ticket.ts": "// triage-ticket.ts \u2014 CLASSIFY EVERY NEW TICKET (a vxil function).\n//\n// Trigger: cmsHook on `tickets`. A hook delivery carries ids, not the row\n// ({ event, collection, item_id }), so the function RE-FETCHES the ticket\n// rather than trusting inline fields \u2014 and delivery is at-least-once, so it\n// skips a ticket that already carries a category instead of re-billing a model\n// call on a redelivery.\n//\n// The one model call is a FORCED-LABEL verdict: `POST /v1/ai/classify` takes the\n// label set and returns exactly one of them (plus a confidence and a one-line\n// rationale). That is the difference between a classifier and a chat prompt \u2014\n// the answer cannot be a paragraph, a new label, or an apology.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\nconst LABELS = ['billing', 'bug', 'how_to', 'other'];\n\ntype Env = CmsHookFunctionEnvelope;\ninterface TicketData { subject?: string; body?: string; category?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const ai = env.scoped_jwts?.ai;\n const itemId = env.payload?.item_id;\n // The platform now filters cms-hook deliveries on the binding's collection/event\n // server-side (guide ch. 8); this guard stays as belt-and-braces.\n if (!cms || !ai || !itemId || env.payload?.collection !== 'tickets') {\n return Response.json({ skipped: true, reason: 'not a tickets hook' });\n }\n // The cms.item.* subscription also delivers updates \u2014 only triage a create.\n if (!String(env.payload?.event ?? '').endsWith('.created')) {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n\n const read = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!read.ok) return Response.json({ skipped: true, reason: `fetch ${read.status}` });\n const ticket = ((await read.json()) as { data?: { data?: TicketData } }).data?.data ?? {};\n // Already triaged \u21D2 this is a redelivery. Do nothing (and pay for nothing).\n if (ticket.category) {\n return Response.json({ skipped: true, reason: 'already triaged', category: ticket.category });\n }\n\n const input = `${ticket.subject ?? ''}\\n\\n${ticket.body ?? ''}`.trim();\n if (!input) return Response.json({ skipped: true, reason: 'empty ticket' });\n\n const verdict = await fetch(`${base}/v1/ai/classify`, {\n method: 'POST',\n headers: { authorization: `Bearer ${ai}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n input,\n labels: LABELS,\n rubric:\n 'billing = money, invoices, refunds or subscriptions. '\n + 'bug = something is broken or behaves incorrectly. '\n + 'how_to = the customer is asking how to do something. '\n + 'other = anything else.',\n }),\n });\n if (!verdict.ok) {\n const detail = await verdict.text();\n return Response.json(\n { error: 'classify_failed', status: verdict.status, detail: detail.slice(0, 300) },\n { status: 502 },\n );\n }\n const v = ((await verdict.json()) as {\n data?: { label?: string; confidence?: number; rationale?: string };\n }).data ?? {};\n const label = LABELS.includes(String(v.label)) ? String(v.label) : 'other';\n\n const patch = await fetch(`${base}/v1/cms/items/tickets/${itemId}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { category: label, state: 'open' } }),\n });\n\n return Response.json({\n item_id: itemId,\n category: label,\n confidence: v.confidence ?? null,\n rationale: v.rationale ?? null,\n written: patch.ok,\n });\n },\n};\n"
20433
20585
  }
20434
20586
  },
20587
+ {
20588
+ "id": "ai-knowledge-base",
20589
+ "title": "AI Knowledge Base (per-workspace search \xB7 grounded answers)",
20590
+ "vertical": "ai",
20591
+ "summary": "A knowledge base per customer workspace. Each team writes its own articles in a workspace-scoped collection. A sync keeps a workspace-scoped search index in step, and every document keeps its row's workspace. A member's searches and cited AI answers draw only on the active workspace's published articles, with no filter to forget and no function to write. An Arabic spelling fold for the keyword lane is one line away.",
20592
+ "collections": [
20593
+ "articles"
20594
+ ],
20595
+ "features": [
20596
+ "auth",
20597
+ "orgs",
20598
+ "cms",
20599
+ "vector-search",
20600
+ "ai",
20601
+ "rag"
20602
+ ],
20603
+ "hasFunctions": false,
20604
+ "byoKeys": [],
20605
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"AI Knowledge Base\" \u2014 a knowledge base PER WORKSPACE: each customer team\n// writes its own articles, searches only its own corpus and gets answers\n// grounded in it, with citations. Declared end-to-end in ONE typed file.\n//\n// \u2022 auth + orgs \u2192 the session acts in ONE active workspace (switchOrg)\n// \u2022 cms \u2192 `articles`, workspace-scoped (orgField)\n// \u2022 vector-search \u2192 `kb`, a WORKSPACE-SCOPED search collection (orgScoped),\n// kept in step with `articles` by a sync: each document\n// keeps its row's workspace\n// \u2022 ai + rag \u2192 a grounded, cited answer over the ACTIVE workspace's\n// documents only, with a prompt template declared here\n//\n// No functions: the workspace boundary is enforced by the platform on every\n// write, sync, search and answer. Runs on the deterministic `mock` embedder and\n// model until you add a key.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n auth: {\n methods: { emailPassword: true, magicLink: true },\n // ACTIVE-ORG MODE: every session acts in one chosen workspace. Required by\n // both `orgField` and `orgScoped` below; turn it on before users sign in.\n orgClaims: { enabled: true, activeOrg: true },\n session: { ttlMinutes: 15, refreshTtlDays: 30 },\n },\n\n orgs: {\n enabled: true,\n maxOrgsPerTenant: 1000,\n maxMembersPerOrg: 100,\n // owner > admin > editor > viewer; `editor` is ours (vxil push creates it)\n roles: [{ role_key: 'editor', name: 'Editor', permissions: ['articles.write'], rank: 2 }],\n },\n\n cms: {\n // Articles are drafted, then published; only PUBLISHED articles are\n // indexed (the sync's statusFilter), so a draft never shows up in an answer.\n draftPublish: true,\n },\n\n 'vector-search': {\n enabled: true,\n // `mock` embeddings need no key. Going live:\n // embed: { provider: 'openai', model: 'text-embedding-3-small', apiKeyRef: 'secret:openai_key', dimensions: 1536 }\n embed: { provider: 'mock', dimensions: 1536 },\n chunking: { maxTokens: 512, overlap: 64 },\n hybrid: { defaultMode: 'hybrid' },\n collections: [\n {\n collection: 'kb',\n dimensions: 1536,\n // WORKSPACE-SCOPED: every document belongs to one workspace. An end\n // user lists, searches and gets answers from its ACTIVE workspace\n // only; a server key must name `org_id` on every ingest and query.\n // Fixed at creation \u2014 it cannot be turned on or off later.\n orgScoped: true,\n // Arabic content? Fold spelling variants in the keyword lane\n // (\u0623\u062D\u0645\u062F = \u0627\u062D\u0645\u062F, \u0645\u062F\u0631\u0633\u0629 = \u0645\u062F\u0631\u0633\u0647, tashkeel ignored). Turning it on for a\n // collection that already holds documents needs a re-ingest.\n // keywordNormalize: 'arabic',\n },\n ],\n sync: [\n {\n source: 'cms',\n cmsCollection: 'articles', // the workspace-scoped collection you author in\u2026\n collection: 'kb', // \u2026feeds the workspace-scoped index (a pairing push checks)\n fields: ['title', 'body'], // what gets embedded\n metadataFields: ['topic', 'lang'], // what stays filterable\n statusFilter: 'published',\n cron: '*/15 * * * *', // the plan minimum on Free; 1 minute from Developer\n },\n ],\n },\n\n ai: {\n enabled: true,\n defaultProvider: 'mock', // BYO key later: providers.openaiKeyRef / anthropicKeyRef / geminiKeyRef\n defaults: { maxTokens: 600, temperature: 0 },\n // DECLARED API STATE: the answer prompt, converged by content on push.\n // rag fills {{context}} (the retrieved passages, fenced) and {{query}}.\n templates: [\n {\n template: 'kb-answer',\n system:\n 'You answer questions for one team from its own knowledge base. Use only the passages provided. '\n + 'If they do not contain the answer, say so plainly. Answer in the language of the question.',\n user: 'Passages:\\n{{context}}\\n\\nQuestion: {{query}}',\n },\n ],\n },\n\n rag: {\n enabled: true,\n defaultCollection: 'kb',\n defaultTemplate: 'kb-answer',\n retrieval: { topK: 6, mode: 'hybrid', minSimilarity: 0.2 },\n context: { maxTokens: 3000, strategy: 'topk' },\n citations: true,\n streaming: true,\n },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n articles: {\n singular: 'article',\n orgField: 'workspace', // stamped with the active workspace on create\n writeRoles: ['editor', 'admin'], // viewers read; the workspace owner always passes\n fields: {\n workspace: { type: 'string', indexSlot: 's1' },\n title: { type: 'string', required: true, indexSlot: 's2' },\n topic: { type: 'string', indexSlot: 's3' }, // e.g. onboarding | billing | product\n lang: { type: 'string', indexSlot: 's4', validation: { regex: '^[a-z]{2}$' } }, // en, ar, fr \u2026\n updated_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text', required: true },\n },\n },\n },\n },\n});\n",
20606
+ "readme": "# AI Knowledge Base (ai)\n\nA **knowledge base per workspace**. Each customer team writes its own articles, searches only its own corpus\nand gets AI answers grounded in it, with citations. One team's articles never appear in another team's search\nresults or answers. The platform enforces that on every write, sync, search and answer, so there is no filter\nin your code to forget.\n\n```bash\nmkdir my-kb && cd my-kb\nvxil init --template ai-knowledge-base\nvxil quickstart --env staging --name my-kb --invite <code> # new backend; pushes the config, writes vxil.types.ts\n # (--invite only for a new e-mail; `vxil link <slug> --env staging` for an existing backend)\n```\n\nNo functions, so this one pushes on any plan and any project workload.\n\n> **Versions.** Workspace-scoped search collections (`orgScoped`) need `@vxil/cli` 0.26.0 and\n> `@vxil/config` 0.19.0 or later to push from code, and `@vxil/sdk` 0.26.0 or later in the app.\n> `activeOrg` needs `@vxil/sdk` 0.25.0 or later.\n\n## What this blueprint teaches\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| One active workspace per session | `features.auth.orgClaims: { enabled: true, activeOrg: true }` | every read, search and answer acts in the workspace the user picked (`vx.auth.sessions.switchOrg`) |\n| Workspace-scoped articles | `orgField: 'workspace'` + `writeRoles: ['editor', 'admin']` | members share them, viewers read them, nobody outside the workspace sees them |\n| A workspace-scoped index | `vector-search.collections[]` with `orgScoped: true` | each document belongs to one workspace; end users search their active one only |\n| The index keeps itself current | `vector-search.sync[]` from `articles` into `kb` | every 15 minutes, published articles are embedded and each document takes its row's workspace |\n| Grounded, cited answers | `features.rag` + the `kb-answer` template in `features.ai.templates` | `vx.rag.answer` grounds on the active workspace's passages only and returns citations |\n| Arabic spelling folding | `keywordNormalize: 'arabic'` on the collection (commented) | `\u0623\u062D\u0645\u062F` matches `\u0627\u062D\u0645\u062F`, `\u0645\u062F\u0631\u0633\u0629` matches `\u0645\u062F\u0631\u0633\u0647`, tashkeel is ignored |\n\n`push` refuses the pairings that would leak. A sync from a workspace collection into an unscoped index is\nrefused, and so is an unscoped source into a workspace index. Pushing them anyway from a hand-rolled client\nrecords the error on the sync, which then ingests nothing.\n\n## Setting up workspaces\n\nIn this mode memberships are server-only: an end-user session cannot create workspaces or change members\n(`403 server_only`). Create them from your backend with a server key:\n\n```bash\nvxil api POST /v1/orgs --data '{\"slug\":\"acme\",\"name\":\"Acme\",\"owner_user_id\":\"<user id>\"}'\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<another user>\",\"role\":\"editor\"}'\n```\n\nTo let users create workspaces and invite teammates themselves, copy the `workspace-members` function from\nthe **team-saas** blueprint: it checks the caller's role and then makes the change as your backend.\n\n## The app\n\nBuild the client with the user's **whole session pair**, from a key that carries `cms:read`, `cms:write`,\n`rag:write`, `vector-search:read` and `orgs:read`:\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nconst vx = new Vxil({ apiKey: PUBLIC_KEY, endUserSession: pair, onSessionChange: (s) => save(s) });\n\n// pick the workspace (the switcher lists the user's own workspaces)\nconst mine = await vx.orgs.list();\nawait vx.auth.sessions.switchOrg(mine[0]!.org_id);\n\n// an editor writes and publishes an article; the workspace field is stamped for you\nconst a = await vx.from('articles').create({ title: 'Refund policy', topic: 'billing', lang: 'en', body: 'Refunds are\u2026' });\nawait vx.from('articles').publish(a.item_id);\n\n// any member asks; the answer draws only on this workspace's published articles\nconst { answer, citations } = await vx.rag.answer({ query: 'How do refunds work?' });\n\n// or just search, optionally narrowed by the synced metadata\nconst hits = await vx.search.query('kb', { query: 'refund', filter: { lang: 'en' } });\n```\n\nA published article becomes searchable on the next sync tick, within 15 minutes. Without an active workspace\n(a new user, or one removed from their workspace), search and answer calls get `403 org_required`: show the\nworkspace switcher. Naming another workspace is `403 other_org`.\n\n**From your server**, name the workspace on every search and answer call (`org_id`), or the call is refused\nwith `422 org_id_required`. A forgotten filter fails closed:\n\n```ts\nawait vx.rag.answer({ query: 'How do refunds work?', org_id: 'org_01J\u2026' });\nawait vx.search.query('kb', { query: 'refund', org_id: 'org_01J\u2026' });\n```\n\n## Arabic content\n\nUncomment `keywordNormalize: 'arabic'` on the `kb` collection before the first push. The keyword lane then\nfolds the indexed text and every query the same way: alef variants become `\u0627`, `\u0649` becomes `\u064A`, `\u0629` becomes\n`\u0647`, and tashkeel and tatweel are removed. Results still return the original text. This is not fuzzy\nmatching: a misspelled word still does not match. To turn it on later, push the change, then re-ingest. For a\nsynced collection, reset the sync so it re-reads every article (guide ch. 6, vector-search). The re-ingest costs\nthe same as the first one.\n\n## Going live\n\n1. Store the provider keys once: `printf '%s' \"$OPENAI_KEY\" | vxil secrets set vector-search/openai_key` (and\n one for the model under `ai`).\n2. Set `embed` to the real provider with the same `dimensions` (1536 for `text-embedding-3-small`), and\n `defaultProvider` plus its key reference under `ai`.\n3. Push, then re-ingest. Vectors from two embedders do not compare, so until the documents are re-ingested\n query responses carry `embed_cfg_stale: true`.\n\n## Costs and limits\n\n- **The workspace filter is free.** It is one more indexed condition in the same query: no extra round trip.\n- **Embedding** costs one call to your embedding provider per changed published article, per sync tick. An\n unchanged article is skipped. On `mock`, embedding costs nothing.\n- **An answer** is one retrieval plus one model call on your provider key, bounded by\n `context.maxTokens` (3,000) and `ai.defaults.maxTokens` (600). `mode: 'keyword'` searches make no\n embedding call at all.\n- **The sync** is one jobs schedule. Free allows 5 active schedules per project at a 15-minute minimum.\n- **Search limits:** 100,000 documents per collection by default (`limits.maxDocsPerCollection`), 1,000 chunks\n per document, 512-token chunks.\n\n## What to learn from this\n\n- **Workspace scoping composes.** `orgField` on the source, `orgScoped` on the index, `activeOrg` on the\n session, and rag inherits it. No layer needs its own copy of the rule.\n- **The server is never silently scoped.** A server key names the workspace on every search, so a missing id\n is an error rather than a cross-workspace answer.\n- **Drafts stay private by construction.** Only published articles are indexed.\n\n**Pairs with:** **team-saas** (the full workspace product: membership function, workspace files, change\nfeeds, workspace billing) and **agent-desk** (classification, judging, copilot and MCP over one shared\nknowledge index).\n",
20607
+ "functions": {}
20608
+ },
20435
20609
  {
20436
20610
  "id": "fal-media",
20437
20611
  "title": "AI Media Studio (fal.ai images \xB7 Veo video \xB7 credits)",
@@ -20662,6 +20836,31 @@ export default defineConfig({
20662
20836
  "sla-sweep.ts": "// sla-sweep.ts \u2014 SLA ESCALATION CRON (a vxil function).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep tickets whose sla_due has passed and\n// that are still open/pending \u2014 the cms filter DSL (vxil.com/docs/guide/04-data-with-cms):\n// `status $in` on the s2 slot, `sla_due $lt` on the t2 slot, `priority $ne` on\n// s3 \u2014 all index-served. Each breach is escalated with a PATCH to priority\n// 'urgent'; the $ne term makes re-runs idempotent (an escalated ticket falls out\n// of the filter). The Lane-A state-machine hook still runs on every PATCH; a\n// priority-only write keeps item.status == before.status, so it always passes.\n\n// cron-walk: drains-filter \u2014 an escalated ticket gets priority 'urgent' and leaves the `$ne` filter.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CronFunctionEnvelope;\ninterface Ticket { item_id: string; data: { subject?: string; priority?: string } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // breached = still open/pending, past its sla_due, not yet urgent\n const filter = enc({\n status: { $in: ['open', 'pending'] },\n sla_due: { $lt: new Date().toISOString() },\n priority: { $ne: 'urgent' },\n });\n const res = await fetch(`${base}/v1/cms/items/tickets?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n if (!res.ok) return Response.json({ error: `query ${res.status}` }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: Ticket[] } };\n const breached = body.data?.items ?? [];\n\n let escalated = 0;\n for (const t of breached) {\n const r = await fetch(`${base}/v1/cms/items/tickets/${t.item_id}`, {\n method: 'PATCH',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({ data: { priority: 'urgent' } }),\n });\n if (r.ok) escalated++;\n }\n return Response.json({ scanned: breached.length, escalated });\n },\n};\n\n// \u2500\u2500 tiny helper (the vxil REST list envelope is { data: { items } }) \u2500\u2500\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n"
20663
20837
  }
20664
20838
  },
20839
+ {
20840
+ "id": "client-portal",
20841
+ "title": "Client Portal (one workspace per client)",
20842
+ "vertical": "support",
20843
+ "summary": "The agency or B2B portal: one workspace per client. The client's people sign in as viewers of their own workspace, and your staff are admins of each client they serve. Clients open and follow requests (a workspace-scoped ticket collection where only staff move the status) and share files that only that client's members can read. Each client has a live request board. A notifier mails staff about new requests and requesters about status changes, sending as that client's portal with a per-send sender.",
20844
+ "collections": [
20845
+ "requests"
20846
+ ],
20847
+ "features": [
20848
+ "auth",
20849
+ "orgs",
20850
+ "cms",
20851
+ "realtime",
20852
+ "files",
20853
+ "notifications",
20854
+ "functions"
20855
+ ],
20856
+ "hasFunctions": true,
20857
+ "byoKeys": [],
20858
+ "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// \"Client Portal\" \u2014 the AGENCY / B2B portal: one WORKSPACE per client. The\n// client's people sign in as members of their own workspace, see only their\n// own requests and shared files, and get mail that comes from THEIR portal.\n// Declared end-to-end in ONE typed file.\n//\n// \u2022 auth + orgs \u2192 one workspace per client; client members are `viewer`s,\n// your staff are `admin`s of every client they serve; the\n// session acts in ONE active workspace (switchOrg)\n// \u2022 cms \u2192 `requests` (tickets), workspace-scoped (orgField): a\n// client opens and follows requests, only staff move them\n// \u2022 files \u2192 shared workspace files: clients upload and read, staff\n// (or the uploader) delete\n// \u2022 realtime \u2192 a live request board per client ('requests:{org}')\n// \u2022 notifications \u2192 mail sent AS the client's portal (a per-send sender:\n// \"Acme portal\" <acme@\u2026>), on new requests and status changes\n// \u2022 functions \u2192 the one notifier\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n auth: {\n methods: { emailPassword: true, magicLink: true },\n orgClaims: { enabled: true, activeOrg: true }, // one active client workspace per session\n session: { ttlMinutes: 15, refreshTtlDays: 30 },\n },\n\n orgs: {\n enabled: true,\n maxOrgsPerTenant: 500, // clients served by this project (up to 100,000)\n maxMembersPerOrg: 25, // client people + your staff, per client\n invitationTtlHours: 168,\n // Built-in roles only: `viewer` for the client's people, `admin` for\n // your staff on that client, `owner` for the account lead.\n },\n\n cms: {\n draftPublish: false,\n hooks: {\n // Who opened it comes from the verified session, never from the body.\n // (Your server key \u2014 `caller` is null \u2014 may set it, e.g. on an import.)\n request_requester: {\n collection: 'requests',\n event: 'beforeCreate',\n kind: 'derive',\n field: 'requester',\n expr: 'isNull(caller) ? item.requester : caller.endUserId',\n },\n request_opened_at: {\n collection: 'requests',\n event: 'beforeCreate',\n kind: 'derive',\n field: 'opened_at',\n expr: 'isNull(caller) ? coalesce(item.opened_at, now) : now',\n },\n // Every request starts as `open` (a server import may set another status).\n request_status_default: {\n collection: 'requests',\n event: 'beforeCreate',\n kind: 'derive',\n field: 'status',\n expr: \"isNull(caller) ? coalesce(item.status, 'open') : 'open'\",\n },\n // A client never writes the notifier's mark or a resolved stamp.\n request_create_rules: {\n collection: 'requests',\n event: 'beforeCreate',\n kind: 'validate',\n expr: 'isNull(caller) || (isNull(item.notified_status) && isNull(item.resolved_at))',\n message: 'a new request is not resolved or notified yet',\n },\n // A client edits only the requests it opened; staff (the workspace's\n // admins and owners) edit any. `caller.org.role` is the ACTIVE\n // workspace role from the verified session. Deletes run no hooks: any\n // member can delete a request in its workspace (see the README).\n request_edit_own: {\n collection: 'requests',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n \"isNull(caller) || caller.org.role == 'admin' || caller.org.role == 'owner'\"\n + ' || before.requester == caller.endUserId',\n message: 'only the requester or agency staff edit a request',\n },\n // Only staff move a request. Nobody signed in rewrites who opened it,\n // when, or the notifier's mark (a rewritten requester would be mailed).\n request_status_staff: {\n collection: 'requests',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'isNull(caller) || (item.notified_status == before.notified_status'\n + ' && item.requester == before.requester && item.opened_at == before.opened_at'\n + \" && (item.status == before.status || caller.org.role == 'admin' || caller.org.role == 'owner'))\",\n message: 'only agency staff change the status of a request; requester and opened_at are fixed',\n },\n // Stamp when a request is resolved; reopening keeps the last stamp. A\n // signed-in caller can never set it by hand.\n request_resolved_at: {\n collection: 'requests',\n event: 'beforeUpdate',\n kind: 'derive',\n field: 'resolved_at',\n expr:\n \"item.status == 'resolved' && before.status != 'resolved' ? now\"\n + ' : (isNull(caller) ? item.resolved_at : before.resolved_at)',\n },\n },\n // A live board per client: each change publishes to org:<client>:requests,\n // which only sessions in that client's workspace can subscribe to.\n cdc: {\n requests_live: { collection: 'requests', channel: 'requests:{org}', events: ['created', 'updated'], payload: 'ids' },\n },\n },\n\n realtime: {},\n\n files: {\n // Shared workspace files (upload with `org: true`). Every member reads\n // them and may upload; deleting needs owner/admin or being the uploader.\n orgScope: { writeRoles: ['owner', 'admin'] },\n },\n\n // The portal's mail. `mock` needs no account. With a real provider, verify\n // the WHOLE fromEmail domain (not one address): every client's portal sends\n // from its own local part on that domain.\n notifications: {\n provider: 'mock',\n fromEmail: 'portal@clients.agency.example',\n fromName: 'Client portal',\n inboxEnabled: true,\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 requests: {\n singular: 'request',\n // WORKSPACE-SCOPED: a client sees its own requests only, and nobody\n // outside the workspace sees them. No writeRoles: every member may open\n // requests; the hooks above confine edits and status moves.\n orgField: 'workspace',\n fields: {\n workspace: { type: 'string', indexSlot: 's1' },\n status: {\n type: 'string',\n indexSlot: 's2',\n validation: { enum: ['open', 'in_progress', 'waiting_on_client', 'resolved'] },\n },\n requester: { type: 'string', indexSlot: 's3' }, // the member who opened it (stamped)\n priority: { type: 'string', indexSlot: 's4', validation: { enum: ['low', 'normal', 'high', 'urgent'] } },\n opened_at: { type: 'datetime', indexSlot: 't1' },\n resolved_at: { type: 'datetime', indexSlot: 't2' },\n title: { type: 'string', required: true, validation: { max: 160 } },\n body: { type: 'text' },\n attachments: { type: 'json' }, // ids of shared workspace files\n // the last status the notifier mailed about (written by the function only)\n notified_status: { type: 'string' },\n },\n },\n },\n },\n\n functions: {\n // THE NOTIFIER. Woken by every committed create and update of a request.\n // New request \u2192 the client's staff (its admins and owners). Status change \u2192\n // the requester. Mail goes out as THAT client's portal (per-send sender).\n 'notify-request': {\n entry: './functions/notify-request.ts',\n trigger: { kind: 'cmsHook', collection: 'requests', event: 'beforeWrite' },\n scopes: ['cms:read', 'cms:write', 'orgs:read', 'notifications:send'],\n egressAllow: [],\n },\n },\n});\n",
20859
+ "readme": "# Client Portal (support)\n\nThe **agency or B2B portal**: one workspace per client. The client's people sign in to a portal that shows\nonly their own requests and shared files. Your staff work across every client they serve. Every mail looks\nlike it comes from that client's own portal.\n\n```bash\nmkdir my-portal && cd my-portal\nvxil init --template client-portal\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # the collection, hooks, change feed and the notifier; writes vxil.types.ts\n```\n\n> **Versions.** Workspace files and workspace change feeds (`{org}` channels) need `@vxil/cli` 0.26.0 and\n> `@vxil/config` 0.19.0 or later to push from code, and `@vxil/sdk` 0.26.0 or later in the app. `activeOrg`\n> needs `@vxil/sdk` 0.25.0 or later.\n>\n> **Plan note.** The notifier deploys on the Free plan when the project's workload is `staging` or\n> `development`. On a Free `production` project, upgrade to Developer, or push with `--skip-functions` (the\n> portal then works without mail).\n\n## What this blueprint teaches\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| One workspace per client | `features.orgs` + `auth.orgClaims: { enabled: true, activeOrg: true }` | a client's people are members of that client only; a staff member in several clients switches with `vx.auth.sessions.switchOrg` |\n| Client-scoped requests | `orgField: 'workspace'` on `requests` | a client reads and opens its own requests; another client's request is a `404` |\n| Staff move the status, clients do not | the `request_status_staff` hook on `caller.org.role` | a client opens requests and edits the text of its own (`request_edit_own`); only the workspace's `admin`s and `owner`s change a status. Deletes run no hooks, so any member can delete a request in its workspace \u2014 route deletes through a function if that matters to you |\n| The requester is who signed in | the `request_requester` derive hook | `requester` (and `opened_at`) come from the verified session and the clock, never from the request body, and no signed-in caller can change them later \u2014 the notifier mails whoever `requester` names |\n| Shared files | `features.files.orgScope` + `vx.files.createUploadUrl({ org: true, \u2026 })` | every member of that client reads and uploads; only staff (or the uploader) delete |\n| A live board per client | `features.cms.cdc` with `channel: 'requests:{org}'` | each change goes to `org:<client>:requests`, which only that client's sessions can subscribe to |\n| Mail from the client's portal | `from_name` / `from_local_part` on each send, in `functions/notify-request.ts` | \"Acme portal\" <acme@clients.agency.example>, on your one verified domain |\n\n## Onboarding a client\n\nMemberships are server-only in this mode: an end-user session cannot create workspaces or add members\n(`403 server_only`). That fits a portal, because you onboard clients. From your backend, or with `vxil api`\nand a server key:\n\n```bash\n# the client's workspace; your account lead owns it\nvxil api POST /v1/orgs --data '{\"slug\":\"acme\",\"name\":\"Acme\",\"owner_user_id\":\"<account lead user id>\"}'\n# your staff on this client\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<staff user id>\",\"role\":\"admin\"}'\n# the client's people: an e-mail invitation as a viewer (vxil sends a best-effort mail; the token comes back once)\nvxil api POST /v1/orgs/org_\u2026/invitations --data '{\"email\":\"dana@acme.com\",\"role\":\"viewer\"}'\n```\n\nThe client accepts from your server, which checks who is accepting:\n`POST /v1/orgs/invitations/accept { token, user_id }` with a server key. Seat them only if the signed-in\nuser's verified e-mail is the invited one. A server-key accept does not compare the addresses for you.\n`orgs.maxMembersPerOrg` (25) caps people per client, staff included.\n\n## The portal\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nconst vx = new Vxil({ apiKey: PUBLIC_KEY, endUserSession: pair, onSessionChange: (s) => save(s) });\n\n// a client's people have one workspace; staff pick the client they are working on\nconst clients = await vx.orgs.list();\nawait vx.auth.sessions.switchOrg(clients[0]!.org_id);\n\n// a client opens a request (workspace, requester, status and opened_at are filled in for them)\nconst r = await vx.from('requests').create({ title: 'Update the pricing page', priority: 'high', body: '\u2026' });\n\n// the board: this client's open requests, newest first\nconst open = await vx.from('requests').query({ filter: { status: { $in: ['open', 'in_progress', 'waiting_on_client'] } }, sort: '-opened_at' });\n\n// staff move it (a client doing this gets 422 \"only agency staff change the status of a request; requester and opened_at are fixed\")\nawait vx.from('requests').patch(r.item_id, { status: 'in_progress' });\n```\n\nAttach a file: upload it into the workspace (`vx.files.createUploadUrl({ org: true, filename, content_type,\nsize_bytes })`, PUT the bytes, `vx.files.complete(object_id)`), then put its id in the request's `attachments`.\n`vx.files.list({ scope: 'org' })` lists everything the client has shared.\n\nFor the live board, open a realtime socket on `org:<client id>:requests` with the user's session and re-read\nthe request a frame names. A token for another client's channel is `403 org_channel_forbidden`.\n\n## The notifier\n\n`notify-request` wakes on every committed create and update of a request.\n\n- **A new request** is mailed to that client's staff (its `admin`s and `owner`s, minus the requester).\n- **A status change** is mailed to the requester.\n- **Every mail is sent as the client's portal.** `from_name` is the workspace name plus \"portal\".\n `from_local_part` is its slug. The domain is always your config `fromEmail` domain, so a send can never\n come from another domain. With a real provider, verify the **whole** domain, not one address, or the sends\n fail at the provider (`vx.notifications.senderDomain()` checks it).\n\nThe function marks each request with the status it mailed about (`notified_status`). Its own write wakes it\nonce more and finds nothing to do. A redelivered event is caught by the same mark and by a per-recipient\nidempotency key. Staff-only hooks let only your backend write the mark. If no mail was accepted, the mark\nstays unset, so the next write to that request tries again. Characters a sender name cannot carry\n(`( ) < > [ ] : ; @ \\ , \"`) are replaced by spaces.\n\n## Costs and limits\n\n- **Reads cost nothing extra.** The client filter is one more indexed condition in the same query.\n- **Per request created:** two function invocations (the mail, then the no-op after its own mark), one batch\n send of at most 25 items (`maxMembersPerOrg`), three reads and one write.\n- **Per status change:** two invocations, one send, three reads and one write. Text edits and other updates\n wake the function, which reads the row and stops.\n- **Functions** on a Free `staging` project run inside 10,000 invocations and 500,000 CPU-ms a day. A\n production project runs functions from Developer up.\n- **Realtime sockets per project:** 100 on Free, 1,000 on Developer, 5,000 on Team. **Files** count towards\n the project's storage quota (Free 10 GiB, Developer 25 GiB, Team 150 GiB).\n\n## What to learn from this\n\n- **A workspace is a customer boundary, not only a team.** One workspace per client gives every client a\n sealed portal from one project and one schema.\n- **Role rules that depend on the row live in hooks.** `caller.org.role` is the active workspace role, so\n \"clients edit their own, staff may move any\" is two expressions, enforced in the write.\n- **The sender belongs to the send.** One verified domain, one local part per client, no per-client mail\n setup.\n\n**Pairs with:** **helpdesk** (requester-owned tickets with an SLA sweep, without workspaces) and\n**team-saas** (workspaces your users create and manage themselves, with workspace billing).\n",
20860
+ "functions": {
20861
+ "notify-request.ts": "// notify-request.ts \u2014 the portal's NOTIFIER (a vxil function).\n//\n// Trigger: cmsHook on `requests` (`beforeWrite`: every committed create and\n// update). The payload carries only { event, collection, item_id }, so the\n// function RE-FETCHES the row. It runs as your backend (a cms-hook delivery has\n// no user), so it reads any workspace's row and its members.\n//\n// new (not mailed) \u2192 mail the client's STAFF (the workspace's admins and\n// owners), except the requester\n// status changed \u2192 mail the REQUESTER (\"your request is now \u2026\")\n// anything else \u2192 nothing\n//\n// The mail goes out AS THAT CLIENT'S PORTAL: a per-send sender\n// from_name: \"Acme portal\", from_local_part: \"acme\" \u2192 \"Acme portal\" <acme@<your fromEmail domain>>\n// The domain is always your config `fromEmail` domain (a send can never come\n// from another domain), so verify the WHOLE domain at your mail provider.\n//\n// `notified_status` on the row is the dedupe mark: after mailing, the function\n// writes the status it mailed about. That write wakes the function once more,\n// and it finds nothing to do. Delivery is at-least-once, so every send also\n// carries an idempotency key derived from the delivery's own key.\n//\n// Bounds per delivery: 3 reads, 1 batch send of at most orgs.maxMembersPerOrg\n// (25) items, 1 write.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CmsHookFunctionEnvelope;\ninterface Request_ {\n workspace?: string;\n status?: string;\n requester?: string;\n title?: string;\n notified_status?: string;\n}\nconst STATUS_WORDS: Record<string, string> = {\n open: 'open',\n in_progress: 'in progress',\n waiting_on_client: 'waiting on you',\n resolved: 'resolved',\n};\n/** The portal link in the mail. Replace with your app's page. */\nconst PORTAL_URL = 'https://portal.example.com/requests/';\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const { cms, orgs, notifications } = env.scoped_jwts ?? {};\n const itemId = env.payload?.item_id;\n if (env.payload?.collection !== 'requests' || !cms || !orgs || !notifications || !itemId) {\n return Response.json({ skipped: true });\n }\n const auth = (t: string) => ({ authorization: `Bearer ${t}`, 'content-type': 'application/json' });\n\n // 1) the row as it is NOW (a later delivery may already have moved it)\n const res = await fetch(`${base}/v1/cms/items/requests/${encodeURIComponent(itemId)}`, { headers: auth(cms) });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const row = ((await res.json()) as { data?: { data?: Request_ } }).data?.data ?? {};\n const status = row.status ?? 'open';\n if (!row.workspace) return Response.json({ skipped: true, reason: 'no workspace' });\n // Already mailed about this status (the write below, or a redelivery).\n if (row.notified_status === status) return Response.json({ skipped: true, reason: 'already notified' });\n // Never mailed yet (no mark) = the NEW-REQUEST mail, whichever delivery\n // gets here first (the create, or an early edit if that mail failed).\n const isNew = !row.notified_status;\n\n // 2) the client workspace: its name and slug become this send's sender\n const orgRes = await fetch(`${base}/v1/orgs/${encodeURIComponent(row.workspace)}`, { headers: auth(orgs) });\n if (!orgRes.ok) return Response.json({ skipped: true, reason: `org ${orgRes.status}` });\n const org = ((await orgRes.json()) as { data?: { name?: string; slug?: string } }).data ?? {};\n const clientName = (org.name ?? 'Client').trim() || 'Client';\n // A sender name refuses control/bidi characters and ( ) < > [ ] : ; @ \\ , \"\n // (422 per item), so the name is cleaned to what the provider header allows.\n const senderName = `${clientName} portal`.replace(/[\\p{Cc}\\u2028\\u2029\\u202A-\\u202E\\u2066-\\u2069()<>[\\]:;@\\\\,\"]/gu, ' ')\n .replace(/\\s+/g, ' ').trim().slice(0, 64);\n const sender = {\n ...(senderName ? { from_name: senderName } : {}),\n // org slugs are a-z, 0-9 and '-', a subset of the local-part alphabet\n ...(org.slug ? { from_local_part: org.slug.slice(0, 40) } : {}),\n };\n\n // 3) who hears about it\n let recipients: string[] = [];\n let subject: string;\n let paragraph: string;\n if (isNew) {\n const memRes = await fetch(`${base}/v1/orgs/${encodeURIComponent(row.workspace)}/members`, { headers: auth(orgs) });\n const members = memRes.ok\n ? (((await memRes.json()) as { data?: { members?: Array<{ user_id: string; role: string }> } }).data?.members ?? [])\n : [];\n recipients = members\n .filter((m) => (m.role === 'admin' || m.role === 'owner') && m.user_id !== row.requester)\n .map((m) => m.user_id);\n subject = `New request from ${clientName}: ${row.title ?? itemId}`;\n paragraph = `${clientName} opened a request${row.title ? ` \"${row.title}\"` : ''}. Open the portal to pick it up.`;\n } else {\n if (row.requester) recipients = [row.requester];\n subject = `Your request is ${STATUS_WORDS[status] ?? status}`;\n paragraph = `\"${row.title ?? itemId}\" is now ${STATUS_WORDS[status] ?? status}.`;\n }\n\n let accepted = 0;\n if (recipients.length) {\n const send = await fetch(`${base}/v1/notifications/send/batch`, {\n method: 'POST',\n headers: auth(notifications),\n body: JSON.stringify({\n items: recipients.slice(0, 100).map((user_id) => ({\n user_id,\n template: 'transactional',\n channel: 'both', // e-mail + the in-app inbox\n data: { subject, paragraph, cta_label: 'Open the request', cta_url: `${PORTAL_URL}${itemId}` },\n ...sender,\n // the new-request mail is keyed on the request (two racing deliveries\n // send it once); a status mail on this delivery\n idempotency_key: (isNew ? `new:${itemId}:${user_id}` : `${env.idempotency_key}:${status}:${user_id}`).slice(0, 255),\n })),\n }),\n });\n // A batch answers 202 even when items fail (each item is its own result),\n // so success is the accepted count, not the status.\n accepted = send.ok ? (((await send.json()) as { data?: { accepted?: number } }).data?.accepted ?? 0) : 0;\n // Nothing went out: leave the mark unset, so the next write to this\n // request tries again. (This answer is recorded, not re-delivered.)\n if (accepted === 0) return Response.json({ notified: 0, status: send.status }, { status: 502 });\n }\n\n // 4) the dedupe mark (a server write: the staff-only hooks let it through)\n await fetch(`${base}/v1/cms/items/requests/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: auth(cms),\n body: JSON.stringify({ data: { notified_status: status } }),\n });\n return Response.json({ notified: accepted, recipients: recipients.length, status });\n },\n};\n"
20862
+ }
20863
+ },
20665
20864
  {
20666
20865
  "id": "crm",
20667
20866
  "title": "Sales CRM",
@@ -20703,11 +20902,38 @@ export default defineConfig({
20703
20902
  "oidc_client_secret"
20704
20903
  ],
20705
20904
  "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 declared here (`vxil push` creates it) and then assigned\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 roles: [\n { role_key: 'finance', name: 'Finance', permissions: ['budgets.approve', 'reports.export'], rank: 2 },\n ],\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",
20706
- "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\nmkdir my-workspace && cd my-workspace\nvxil init --template team-workspace\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections + hooks + the archive function; writes vxil.types.ts\nvxil seed # the demo project row\n```\n\n> **Plan note.** The archive function deploys on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n## 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 | `features.orgs.roles` | `vxil push` creates or updates it; a live role you never declared is reported, not deleted |\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 start sign-in from your app. A plain link to `GET /v1/auth/oauth/oidc/start` does not work from a\nbrowser: that route needs your project key in a header, which a navigation cannot carry. Ask for the\nauthorize URL as JSON instead, with a public key that carries `auth:signin`:\n\n1. `const { authorize_url } = await vx.auth.oauth.startUrl(\'oidc\', { redirect_uri: \'https://app.example.com/callback\' })`,\n then `location.assign(authorize_url)`.\n2. The identity provider sends the browser back to `redirect_uri` with `?code=\u2026&state=\u2026`.\n3. Your callback page hands both to `GET /v1/auth/oauth/oidc/callback?code=\u2026&state=\u2026` (same key), which\n answers the session.\n\n`redirect_uri` must match `security.allowedRedirectOrigins`, which this blueprint sets to\n`https://app.example.com` \u2014 change it to your app\'s origin, or the start answers `422\nredirect_not_allowed`. The state expires after `expires_in` seconds (600). The full flow is in\nvxil.com/docs/guide/06-feature-catalog ("Starting social or SSO sign-in from a browser app").\n\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 HTTP 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`. The project key\nquickstart minted has no `auth:write` (it is the key most likely to end up in a client), so mint this one:\n`vxil login`, then `vxil keys mint --name ws-server --scopes cms:read,cms:write,orgs:read,orgs:write,auth:signin,auth:write,features:read --json`\nand set `KEY` to its `api_key`.\n\n**Reading the output comments.** Each `# 200 { "data": \u2026 }` comment shows the HTTP response. A raw\n`curl` call prints exactly that; a `vxil api` line prints only what is inside `data` (no status, no\nenvelope), ready for `jq` \u2014 so `jq .org_id`, not `jq .data.org_id` \u2014 and an error goes to stderr as\n`code \u2014 message`.\n\n**1. Check the `finance` role.** It is declared in `features.orgs.roles`, so `vxil push` already\ncreated it (change the block and push again to edit it).\n\n```bash\nvxil api GET /v1/orgs/roles | jq \'.custom\'\n# 200 { "data": { "builtin": [...], "custom": [ { "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\nPROJECT=$(vxil api GET \'/v1/cms/items/projects?filter={"code":"NW-2026"}\' | jq -r \'.items[0].item_id\') # the seeded project\nvxil api POST /v1/cms/items/documents --data \'{"data":{"title":"Statement of work","author":"<dana>","project":"\'$PROJECT\'","state":"draft","budget_usd":240000}}\'\nvxil api POST /v1/cms/items/documents --data \'{"data":{"title":"Rollout plan","author":"<alice>","project":"\'$PROJECT\'","state":"draft","budget_usd":18000}}\'\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.** Documents are owner-scoped on `author`, so each member reads their own\ndocument, by sending the session\'s `token` in the `X-Vxil-End-User` header (`<id>` is the\nStatement of work):\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/<the Rollout plan 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 two documents pointing at it:\n\n```bash\nvxil api DELETE /v1/cms/items/projects/$PROJECT\n# 409 { "error": { "code": "referenced",\n# "message": "this item is still referenced by 2 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" }, \u2026 ],\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/backlinks\n# 200 { "data": { "collection": "projects", "item_id": "itm_\u2026",\n# "backlinks": [ { "collection": "documents", "field": "project", "item_id": "itm_\u2026", "status": "draft" }, \u2026 ],\n# "count": 2, "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- **The role and the gate are both config.** `finance` is one entry in `orgs.roles`, converged by\n `vxil push`; `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:** the **crm** blueprint (the same relational spine without the org layer) and\nthe **helpdesk** blueprint (owner-scoped records with a state machine).\n',
20905
+ "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\nmkdir my-workspace && cd my-workspace\nvxil init --template team-workspace\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections + hooks + the archive function; writes vxil.types.ts\nvxil seed # the demo project row\n```\n\n> **Plan note.** The archive function deploys on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\n## 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 | `features.orgs.roles` | `vxil push` creates or updates it; a live role you never declared is reported, not deleted |\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 start sign-in from your app. A plain link to `GET /v1/auth/oauth/oidc/start` does not work from a\nbrowser: that route needs your project key in a header, which a navigation cannot carry. Ask for the\nauthorize URL as JSON instead, with a public key that carries `auth:signin`:\n\n1. `const { authorize_url } = await vx.auth.oauth.startUrl(\'oidc\', { redirect_uri: \'https://app.example.com/callback\' })`,\n then `location.assign(authorize_url)`.\n2. The identity provider sends the browser back to `redirect_uri` with `?code=\u2026&state=\u2026`.\n3. Your callback page hands both to `GET /v1/auth/oauth/oidc/callback?code=\u2026&state=\u2026` (same key), which\n answers the session.\n\n`redirect_uri` must match `security.allowedRedirectOrigins`, which this blueprint sets to\n`https://app.example.com` \u2014 change it to your app\'s origin, or the start answers `422\nredirect_not_allowed`. The state expires after `expires_in` seconds (600). The full flow is in\nvxil.com/docs/guide/06-feature-catalog ("Starting social or SSO sign-in from a browser app").\n\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`, `viewer` or one of your declared custom roles (`orgs.roles`).\n The single-use token comes back **once**, in that response. vxil also sends the invitee a short\n best-effort email (without the token) when you pass `email`; your app builds the accept link and\n controls 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 A link invite (no `email`) can be accepted by whoever holds the token. 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 HTTP 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`. The project key\nquickstart minted has no `auth:write` (it is the key most likely to end up in a client), so mint this one:\n`vxil login`, then `vxil keys mint --name ws-server --scopes cms:read,cms:write,orgs:read,orgs:write,auth:signin,auth:write,features:read --json`\nand set `KEY` to its `api_key`.\n\n**Reading the output comments.** Each `# 200 { "data": \u2026 }` comment shows the HTTP response. A raw\n`curl` call prints exactly that; a `vxil api` line prints only what is inside `data` (no status, no\nenvelope), ready for `jq` \u2014 so `jq .org_id`, not `jq .data.org_id` \u2014 and an error goes to stderr as\n`code \u2014 message`.\n\n**1. Check the `finance` role.** It is declared in `features.orgs.roles`, so `vxil push` already\ncreated it (change the block and push again to edit it).\n\n```bash\nvxil api GET /v1/orgs/roles | jq \'.custom\'\n# 200 { "data": { "builtin": [...], "custom": [ { "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\nPROJECT=$(vxil api GET \'/v1/cms/items/projects?filter={"code":"NW-2026"}\' | jq -r \'.items[0].item_id\') # the seeded project\nvxil api POST /v1/cms/items/documents --data \'{"data":{"title":"Statement of work","author":"<dana>","project":"\'$PROJECT\'","state":"draft","budget_usd":240000}}\'\nvxil api POST /v1/cms/items/documents --data \'{"data":{"title":"Rollout plan","author":"<alice>","project":"\'$PROJECT\'","state":"draft","budget_usd":18000}}\'\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.** Documents are owner-scoped on `author`, so each member reads their own\ndocument, by sending the session\'s `token` in the `X-Vxil-End-User` header (`<id>` is the\nStatement of work):\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/<the Rollout plan 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 two documents pointing at it:\n\n```bash\nvxil api DELETE /v1/cms/items/projects/$PROJECT\n# 409 { "error": { "code": "referenced",\n# "message": "this item is still referenced by 2 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" }, \u2026 ],\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/backlinks\n# 200 { "data": { "collection": "projects", "item_id": "itm_\u2026",\n# "backlinks": [ { "collection": "documents", "field": "project", "item_id": "itm_\u2026", "status": "draft" }, \u2026 ],\n# "count": 2, "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- **The role and the gate are both config.** `finance` is one entry in `orgs.roles`, converged by\n `vxil push`; `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:** the **crm** blueprint (the same relational spine without the org layer) and\nthe **helpdesk** blueprint (owner-scoped records with a state machine).\n',
20707
20906
  "functions": {
20708
20907
  "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\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\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}\ntype Env = HttpFunctionEnvelope<ActionPayload>;\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 (guide ch. 8); 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"
20709
20908
  }
20710
20909
  },
20910
+ {
20911
+ "id": "team-saas",
20912
+ "title": "Team SaaS (workspaces \xB7 roles \xB7 workspace billing)",
20913
+ "vertical": "saas",
20914
+ "summary": "The canonical B2B team product: each customer gets workspaces, and the session acts in one active workspace the app switches with switchOrg. Folders (a parent self-relation) and docs are workspace-scoped, so members share rows and nobody else sees them. Roles are owner/admin/editor/viewer. Each workspace has its own live change feed and its own files that outlive the uploader. The workspace is the billing subject, on a free and a team tier. Link invites with custom roles and every membership change go through one role-checked server function.",
20915
+ "collections": [
20916
+ "folders",
20917
+ "docs"
20918
+ ],
20919
+ "features": [
20920
+ "auth",
20921
+ "orgs",
20922
+ "cms",
20923
+ "realtime",
20924
+ "files",
20925
+ "payments",
20926
+ "functions"
20927
+ ],
20928
+ "hasFunctions": true,
20929
+ "byoKeys": [],
20930
+ "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 SaaS\" \u2014 the canonical B2B team product: every customer gets WORKSPACES,\n// each workspace shares its own folders, docs and files, sees its own live\n// changes, and carries its own plan. Declared end-to-end in ONE typed file.\n//\n// \u2022 auth \u2192 the session acts in ONE active workspace (orgClaims.activeOrg);\n// the app switches with vx.auth.sessions.switchOrg(orgId)\n// \u2022 orgs \u2192 workspaces + memberships; roles owner > admin > editor > viewer\n// (`editor` is a CUSTOM role declared below)\n// \u2022 cms \u2192 `folders` (a parent self-relation) and `docs`, both\n// WORKSPACE-SCOPED (orgField): members share rows, nobody else\n// sees them; one change feed per workspace ('docs:{org}')\n// \u2022 files \u2192 workspace-owned files (upload with `org: true`) that survive\n// the uploader's erasure\n// \u2022 payments \u2192 a payments INTEGRATION (your own provider account; vxil never\n// in the flow of funds) with the WORKSPACE as the billing subject:\n// a `free` tier every new workspace gets, and a paid `team` tier\n// \u2022 functions \u2192 the membership changes (server-only in this mode) and the\n// team checkout, each behind a role check of the caller\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n auth: {\n methods: { emailPassword: true, magicLink: true },\n // ACTIVE-ORG MODE. The session carries ONE chosen workspace and keeps it:\n // the first sign-in picks the most recent membership, switchOrg moves it,\n // a refresh never falls back to another workspace. The workspace role\n // gates ONLY workspace-scoped collections \u2014 owning a workspace never\n // opens an unscoped one. Turn it on before end users sign in.\n orgClaims: { enabled: true, activeOrg: true },\n // A membership change reaches live sessions in about 60 s; if that notice\n // cannot be delivered, the access-token TTL is the bound \u2014 keep it short.\n session: { ttlMinutes: 15, refreshTtlDays: 30 },\n },\n\n orgs: {\n enabled: true,\n // One project serves many customer workspaces. The org cap is a count of\n // workspaces in THIS project (schema max 100,000); raise it as you grow.\n maxOrgsPerTenant: 1000,\n // The hard seat ceiling per workspace; the PLAN's seat quota (payments\n // below) is the commercial one the membership function enforces.\n maxMembersPerOrg: 50,\n invitationTtlHours: 72,\n // The built-ins are owner > admin > member > viewer. `editor` is ours: a\n // plain role key the cms gates below name. The permission strings are\n // yours too \u2014 vxil only answers whether a member holds one.\n roles: [\n { role_key: 'editor', name: 'Editor', permissions: ['docs.write', 'folders.write'], rank: 2 },\n ],\n },\n\n cms: {\n // Docs are live the moment they are written \u2014 a workspace tool, not a CMS.\n draftPublish: false,\n // ONE CHANGE FEED PER WORKSPACE. `{org}` resolves per row from the\n // collection's orgField: a change to a doc of workspace W publishes to\n // `org:W:docs`, and only a session whose ACTIVE workspace is W can mint a\n // token for that channel. Frames carry ids only \u2014 the client re-reads the\n // row, and the workspace rule applies there.\n cdc: {\n docs_live: { collection: 'docs', channel: 'docs:{org}', events: ['created', 'updated', 'deleted'], payload: 'ids' },\n folders_live: { collection: 'folders', channel: 'folders:{org}', events: ['created', 'updated', 'deleted'], payload: 'ids' },\n },\n },\n\n realtime: {}, // the sockets the change feeds above publish to\n\n files: {\n // WORKSPACE FILES. Any member of the active workspace reads a file the\n // workspace owns; deleting it (and its TTL, OCR, shared links) needs one\n // of these roles or being the uploader. The workspace `owner` always passes.\n orgScope: { writeRoles: ['owner', 'admin', 'editor'] },\n },\n\n // A payments INTEGRATION: the tenant's own Stripe/Paddle/PayPal account;\n // vxil records what the provider reports and is never in the flow of funds.\n // `mock` runs everything with no account. Go live with your provider block,\n // e.g. paddle: { apiKeyRef: 'paddle_api_key', webhookSecretRef: 'paddle_webhook' }.\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n productMap: {},\n // The workspace's plan. The higher `rank` wins when a workspace holds\n // both: `free` is granted once when the workspace is created (by the\n // membership function), `team` comes from the provider subscription.\n tierMap: {\n free: { entitlements: ['workspace'], quotas: { seats: 3 }, rank: 0 },\n team: { entitlements: ['workspace', 'team'], quotas: { seats: 50 }, rank: 10 },\n },\n // your provider's price id \u2192 tier (replace with the real one)\n priceMap: { pri_team_monthly: 'team' },\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 // A folder needs no access control of its own: it is a row of the\n // workspace, so every member sees the whole tree and `writeRoles` decides\n // who may change it. Nest with `parent` (null = a root folder).\n folders: {\n singular: 'folder',\n orgField: 'workspace', // stamped with the session's active workspace on create\n writeRoles: ['editor', 'admin'], // matched against the ACTIVE-workspace role; `owner` always passes\n fields: {\n workspace: { type: 'string', indexSlot: 's1' },\n // `restrict`: a folder that still has sub-folders cannot be deleted \u2014\n // delete a tree bottom-up (guide ch. 4, \"Folders in a workspace\").\n parent: { type: 'relation', relationTo: 'folders', onDelete: 'restrict', indexSlot: 's2' },\n // \"/<rootId>/<childId>/\u2026\" \u2014 lists a whole subtree in one\n // `{ path: { $startsWith } }` query. Nothing on the server sets it or\n // stops a cycle: your app (or a function) writes `path` and, on a\n // move, refuses a `parent` whose path starts with the folder's own.\n path: { type: 'string', indexSlot: 's3' },\n name: { type: 'string', required: true, indexSlot: 's4' },\n position: { type: 'float', indexSlot: 'n1' },\n created_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n\n docs: {\n singular: 'doc',\n orgField: 'workspace',\n writeRoles: ['editor', 'admin'], // viewers read, editors and admins write\n fields: {\n workspace: { type: 'string', indexSlot: 's1' },\n // a doc can only be filed in a folder of the SAME workspace\n // (422 relation_not_in_org otherwise), and a folder holding docs is\n // not deletable until they move\n folder: { type: 'relation', relationTo: 'folders', onDelete: 'restrict', indexSlot: 's2' },\n title: { type: 'string', required: true, indexSlot: 's3' },\n author: { type: 'string', indexSlot: 's4' }, // the member who wrote it (display only)\n word_count: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text' },\n attachments: { type: 'json' }, // ids of workspace files (vx.files.createUploadUrl({ org: true, \u2026 }))\n },\n },\n },\n },\n\n // \u2500\u2500 The steps that are not 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\u2500\n functions: {\n // MEMBERSHIP CHANGES. In active-org mode an end-user session cannot write\n // orgs at all (403 server_only), because a membership IS the data boundary.\n // The app calls this function AS the signed-in user; it checks the caller's\n // role with the caller's own token (the verified identity, never the\n // payload), then writes with `server_jwts`, tokens not bound to the user.\n // Actions: create \xB7 invite \xB7 accept \xB7 set_role \xB7 remove.\n 'workspace-members': {\n entry: './functions/workspace-members.ts',\n trigger: { kind: 'http', serverScopes: ['orgs:read', 'orgs:write', 'payments:read', 'payments:write'] },\n scopes: ['orgs:read', 'orgs:write', 'payments:read', 'payments:write'],\n egressAllow: [], // nothing external; it only talks back to your own backend\n signature: {\n // the values are TypeScript types, emitted verbatim by `vxil gen`\n input: {\n action: \"'create' | 'invite' | 'accept' | 'set_role' | 'remove'\",\n org_id: 'string | undefined',\n slug: 'string | undefined',\n name: 'string | undefined',\n role: 'string | undefined',\n user_id: 'string | undefined',\n token: 'string | undefined',\n },\n },\n },\n\n // THE TEAM CHECKOUT. A workspace admin opens a provider checkout whose\n // SUBJECT is the workspace (`org_id`), so the plan belongs to the workspace,\n // not to whoever paid, and survives an ownership hand-over.\n 'workspace-billing': {\n entry: './functions/workspace-billing.ts',\n trigger: { kind: 'http', serverScopes: ['payments:write'] },\n scopes: ['orgs:read', 'payments:write'],\n egressAllow: [],\n signature: {\n input: { org_id: 'string', success_url: 'string | undefined', cancel_url: 'string | undefined' },\n output: '{ url: string }',\n },\n },\n },\n});\n",
20931
+ "readme": "# Team SaaS (saas)\n\nThe canonical **B2B team product**. Each customer gets one or more **workspaces**. A workspace shares its\nown folders, docs and files, sees its own live changes, and carries its own plan. A signed-in user acts in\n**one active workspace** at a time and switches between them like in any team tool.\n\nEverything a \"team SaaS\" usually builds by hand around its tables is declared here instead: the workspace\nboundary on every read and write, roles inside a workspace, a change feed per workspace, workspace-owned\nfiles and a plan per workspace. The two steps that need product rules (who may invite, change roles or buy\nthe plan) are two small functions.\n\n```bash\nmkdir my-team-app && cd my-team-app\nvxil init --template team-saas\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections, roles, plans, change feeds, both functions; writes vxil.types.ts\n```\n\n> **Versions.** Workspace files, workspace change feeds (`{org}` channels) and the client half of this\n> blueprint need `@vxil/cli` 0.26.0 and `@vxil/config` 0.19.0 or later (older versions refuse the `{org}`\n> channel when they validate locally), and the app needs `@vxil/sdk` 0.26.0 or later. `activeOrg` itself\n> needs `@vxil/sdk` 0.25.0 or later in the app: older SDKs keep getting `401 session_stale` after a membership\n> change until the access token expires.\n>\n> **Plan note.** The two functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload` step above). On a Free `production` project, `vxil push` stops\n> before it writes anything and names the ways out: change the workload, upgrade to Developer, or push with\n> `--skip-functions` (the workspace data model works without them, but membership changes then need your own\n> server).\n\n## What this blueprint teaches\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| One active workspace per session | `features.auth.orgClaims: { enabled: true, activeOrg: true }` | the session carries one chosen workspace; `vx.auth.sessions.switchOrg(orgId)` moves it; a refresh never silently falls back to another workspace |\n| Roles inside a workspace | `features.orgs.roles` (`editor`) + the built-in `owner`, `admin`, `viewer` | `vxil push` creates `editor`; the workspace role gates only workspace collections, so owning a workspace never opens anything else |\n| Workspace-scoped rows | `orgField: 'workspace'` on `folders` and `docs` | members share the rows; another workspace's row is a `404`; a create is stamped with the active workspace |\n| Who may write | `writeRoles: ['editor', 'admin']` | viewers read, editors and admins write; the workspace `owner` always passes |\n| Folders and sub-folders | `folders.parent` (a relation to `folders`) + `path` | list a folder with `{ parent: id }`, the root with `{ parent: null }`, a subtree with `{ path: { $startsWith } }`. The server does not maintain `path` or prevent cycles: your app writes `path`, and on a move refuses a new `parent` whose `path` starts with the moved folder's own |\n| A change feed per workspace | `features.cms.cdc` with `channel: 'docs:{org}'` | each change goes to `org:<workspace id>:docs`, and only a session whose active workspace that is can subscribe |\n| Workspace files | `features.files.orgScope` + `vx.files.createUploadUrl({ org: true, \u2026 })` | any member reads them; they stay when the uploader's account is erased; deleting the workspace deletes them |\n| The workspace pays | `features.payments.ledger.tierMap` (`free`, `team`) + `org_id` on the checkout | the plan's subject is `org:<workspace id>`: every member reads it, and an ownership hand-over changes nothing |\n| Membership changes | `functions.workspace-members` with `serverScopes` | the caller's role is checked with the caller's own token, then the write is made as your backend |\n\n## The workspace flow in the app\n\nBuild the client with the **whole session pair**, so the SDK can rotate it on `switchOrg` and on\n`session_stale`:\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// `pair` is the { token, refresh_token, \u2026 } session from sign-in; `save` persists it (a cookie, storage)\nconst vx = new Vxil({ apiKey: PUBLIC_KEY, endUserSession: pair, onSessionChange: (s) => save(s) });\n\n// 1) a brand-new user creates their first workspace (they become its owner, on the free plan)\nconst ws = await vx.fn['workspace-members']({ action: 'create', slug: 'acme', name: 'Acme' });\nawait vx.auth.sessions.switchOrg(ws.org_id);\n\n// 2) the workspace switcher: the user's workspaces, each with their role\nconst mine = await vx.orgs.list();\nawait vx.auth.sessions.switchOrg(mine[0]!.org_id);\nvx.auth.session.org; // \u2192 { id, role, perms } of the active workspace\n\n// 3) everything below acts in the active workspace, with no workspace id in the call\nconst root = await vx.from('folders').create({ name: 'Specs' });\nawait vx.from('docs').create({ title: 'Kickoff', folder: root.item_id, body: '\u2026' });\nconst inRoot = await vx.from('docs').query({ filter: { folder: root.item_id } });\n```\n\nThe `workspace` field is stamped for you. Naming another workspace is `403 other_org`. A session with no\nactive workspace gets `403 org_required` (send the user to the switcher). A doc filed in another\nworkspace's folder is `422 relation_not_in_org`.\n\n**Removed from a workspace?** Within about 60 seconds the user's next request answers `401 session_stale`. The\nSDK refreshes the pair once. The new pair carries the new role, or no workspace at all after a removal (the\nrefresh answer's `org` is `null`: show the switcher). Then it retries the request. An answer with\n`org_unavailable: true` means the workspace service could not be reached: keep the workspace and try again.\nDo not switch on it.\n\n## Inviting a teammate\n\nIn this mode an end-user session cannot write memberships at all (`403 server_only`): a membership decides\nwhich workspace's rows a session reads, so it is a server decision. The app calls the `workspace-members`\nfunction instead. The function checks the caller's role and then acts as your backend:\n\n```ts\n// an admin or owner of the active workspace\nconst { invite_token } = await vx.fn['workspace-members']({ action: 'invite', org_id: ws.org_id, role: 'editor' });\n// build your own accept link and deliver it, e.g. https://app.example.com/join?token=\u2026\n\n// the invitee, signed in, on that page\nconst joined = await vx.fn['workspace-members']({ action: 'accept', token });\nawait vx.auth.sessions.switchOrg(joined.org_id);\n```\n\n- **Link invites.** The function issues invitations without an e-mail address, so vxil sends nothing and\n anyone signed in who holds the link can take the seat once. The token is returned once and expires after\n `orgs.invitationTtlHours` (72 here). To bind an invitation to one address instead, create it with an `email`\n from your server and check the accepting user's verified address in the function before you accept: a\n server-key accept does not compare the addresses for you.\n- **Seats come from the plan.** An invite is refused with `402 seats_exhausted` when members plus pending\n invitations reach the workspace's `seats` quota (3 on `free`, 50 on `team`). `orgs.maxMembersPerOrg`\n (50) is the hard ceiling behind it.\n- **Roles.** An invite carries `admin`, `editor` or `viewer`. `set_role` changes a member's role (admin or\n owner). Granting or taking `owner` needs an owner, and a workspace always keeps one owner (`409`). `remove`\n removes a member, or lets a user leave (`user_id` = themselves).\n- **Workspaces per user.** `create` refuses a sixth owned workspace (`MAX_OWNED_WORKSPACES` in the function).\n `orgs.maxOrgsPerTenant` (1,000 here, up to 100,000) is shared by every user of the project.\n\n## Live changes in the active workspace\n\n`docs_live` publishes every created, updated or deleted doc of workspace W to `org:W:docs`, with ids only.\nMint a connect token for that channel with the user's session, subscribe, and re-read the row a frame names\n(the workspace rule applies to the re-read):\n\n```ts\nconst channel = `org:${vx.auth.session.org!.id}:docs`;\n// open the socket with vx.realtime.tokenProvider({ user_id }) as the token provider (guide ch. 6, realtime)\n```\n\nA token for another workspace's channel is `403 org_channel_forbidden`. After `switchOrg`, mint a token for\nthe new workspace's channel. A frame is a hint, not the data, so a client that missed one loses nothing it\ncannot re-read.\n\n## Workspace files\n\n```ts\nconst up = await vx.files.createUploadUrl({ org: true, filename: 'brief.pdf', content_type: 'application/pdf', size_bytes: 48_211 });\nawait fetch(up.upload_url, { method: 'PUT', headers: { 'content-type': 'application/pdf' }, body: bytes });\nawait vx.files.complete(up.object_id);\nconst shared = await vx.files.list({ scope: 'org' }); // the active workspace's files\n```\n\nAny member of the workspace can upload into it, read its files and mint download URLs. Deleting a file, its\nTTL, OCR and shared links need `owner`, `admin` or `editor` (`files.orgScope.writeRoles`), or being the\nuploader. Outside its workspace a workspace file is a `404`, for the uploader too. Erasing the uploader keeps\nthe file; deleting the workspace deletes its files in the background.\n\n## Billing the workspace\n\nThe plan is a **payments integration**: your own Stripe, Paddle or PayPal account. vxil records what the\nprovider reports and is never in the flow of funds. This blueprint runs on `mock` until you add your\nprovider block and replace `pri_team_monthly` in `ledger.priceMap` with your real price id.\n\n- **`free`.** `create` grants it once to every new workspace (a manual, open-ended plan whose subject is the\n workspace).\n- **`team`.** A workspace admin opens the checkout through the `workspace-billing` function:\n `const { url } = await vx.fn['workspace-billing']({ org_id })`, then `location.assign(url)`. The checkout's\n subject is the workspace, so the subscription folds onto `org:<workspace id>`. It outranks `free` (`rank`\n 10 against 0).\n- **Reading it.** Any member reads the active workspace's plan:\n `await vx.payments.hasEntitlement({ subject: 'org' }, 'team')`. Your server reads any workspace's plan with\n `{ org_id }`.\n\n## Costs and limits\n\n- **Reads cost nothing extra.** The workspace is one more indexed condition in the same query: no added round\n trip, no added read.\n- **A membership change** is one function invocation (at most 4 reads and 2 writes against your own backend,\n no fan-out). The billing function is one read and one write. On a Free `staging` project, functions run\n inside 10,000 invocations and 500,000 CPU-ms a day. A production project runs functions from Developer up.\n- **Change feeds** publish one frame per changed row, ids only. Realtime sockets per project follow your\n plan: 100 on Free, 1,000 on Developer, 5,000 on Team.\n- **Files** count towards the project's storage quota like every file (Free 10 GiB, Developer 25 GiB, Team\n 150 GiB).\n- **Payments.** vxil adds no fee to the money flow. Your provider charges its own fees.\n\n## What to learn from this\n\n- **The workspace boundary is config, not code.** `orgField` puts the workspace predicate on every end-user\n read, write, count, join, cascade and change feed. Your server key is never affected.\n- **A membership is a data boundary, so it changes on the server.** The workspaceGuard pattern is three\n lines: who (`env.end_user`), may they (`/check` with their own token), then act (`server_jwts`).\n- **The workspace is the customer.** It holds the plan, the files and the rows, so people can come and go\n without anything being re-keyed.\n\n**Pairs with:** **team-workspace** (orgs with SSO, field-level security and one action button, without the\nactive workspace) and **ai-knowledge-base** (this workspace model plus search and grounded answers).\n",
20932
+ "functions": {
20933
+ "workspace-billing.ts": "// workspace-billing.ts \u2014 open the TEAM checkout for a workspace (a vxil function).\n//\n// Called by the app AS the signed-in user:\n// const { url } = await vx.fn['workspace-billing']({ org_id }) // then location.assign(url)\n//\n// It is a payments INTEGRATION: the checkout is your own provider's (Stripe,\n// Paddle, PayPal); vxil opens it and later records what the provider reports.\n// vxil is never in the flow of funds.\n//\n// The checkout's SUBJECT is the workspace (`org_id`), so the provider event folds\n// onto `org:<org_id>`: the `team` tier belongs to the workspace, every member\n// reads it (`vx.payments.hasEntitlement({ subject: 'org' }, 'team')`), and it\n// stays when the workspace changes owner. The paying member rides along as\n// `user_id` \u2014 tagged on the checkout, never entitled.\n//\n// Only an admin or owner of THAT workspace may open it (checked with the\n// caller's own token, so it is the verified identity and the live membership).\n// The price is fixed here, never taken from the payload. Bounds: one read, one\n// write per call.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = HttpFunctionEnvelope<{ org_id?: string; success_url?: string; cancel_url?: string }>;\n\n/** Your provider's price id for the team plan \u2014 the `priceMap` key in vxil.config.ts. */\nconst TEAM_PRICE = 'pri_team_monthly';\n/** Where the provider sends the payer back. Replace with your app's pages. */\nconst APP_ORIGIN = 'https://app.example.com';\n\nconst fail = (status: number, code: string, message: string): Response =>\n Response.json({ error: { code, message } }, { status });\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 me = env.end_user;\n if (!me || me.anon) return fail(401, 'sign_in_required', 'call this function with a signed-in user session');\n const userOrgs = env.scoped_jwts?.orgs;\n const pay = env.server_jwts?.payments;\n if (!userOrgs || !pay) return fail(500, 'misconfigured', 'orgs:read scope or payments:write serverScope is missing');\n const orgId = String(env.payload?.org_id ?? '');\n if (!orgId) return fail(422, 'validation_failed', 'need { org_id }');\n\n // workspaceGuard: billing is an admin decision in THIS workspace\n const check = await fetch(\n `${base}/v1/orgs/${encodeURIComponent(orgId)}/check?user_id=${encodeURIComponent(me.id)}&permission=members:manage`,\n { headers: { authorization: `Bearer ${userOrgs}` } },\n );\n const verdict = ((await check.json().catch(() => ({}))) as { data?: { allowed?: boolean } }).data;\n if (!check.ok || verdict?.allowed !== true) return fail(403, 'forbidden', 'only a workspace admin or owner may change the plan');\n\n // Only your own pages may be return targets.\n const back = (u: unknown, fallback: string): string =>\n typeof u === 'string' && u.startsWith(`${APP_ORIGIN}/`) ? u : fallback;\n\n const sess = await fetch(`${base}/v1/payments/checkout-sessions`, {\n method: 'POST',\n // One key per INVOCATION (the runtime's per-call key, or the caller's\n // Idempotency-Key header): a retry of this call replays the same session,\n // and a later purchase gets a fresh one.\n headers: { authorization: `Bearer ${pay}`, 'content-type': 'application/json', 'idempotency-key': env.idempotency_key },\n body: JSON.stringify({\n org_id: orgId, // the workspace gets the plan\n user_id: me.id, // the member paying (tagged, not entitled)\n mode: 'subscription',\n line_items: [{ price_ref: TEAM_PRICE, quantity: 1 }],\n success_url: back(env.payload?.success_url, `${APP_ORIGIN}/billing/ok`),\n cancel_url: back(env.payload?.cancel_url, `${APP_ORIGIN}/billing`),\n }),\n });\n const out = (await sess.json().catch(() => ({}))) as { data?: { url?: string }; error?: { code?: string; message?: string } };\n if (!sess.ok || !out.data?.url) {\n return fail(sess.status >= 400 ? sess.status : 502, out.error?.code ?? 'checkout_failed', out.error?.message ?? 'the provider checkout could not be opened');\n }\n return Response.json({ url: out.data.url });\n },\n};\n",
20934
+ "workspace-members.ts": "// workspace-members.ts \u2014 every MEMBERSHIP change of a workspace (a vxil function).\n//\n// Why a function: in active-org mode (auth.orgClaims.activeOrg) an org\n// membership IS the data boundary \u2014 it decides which workspace rows a session\n// reads \u2014 so an end-user session gets 403 server_only on every orgs write. The\n// app calls this function AS the signed-in user instead:\n//\n// await vx.fn['workspace-members']({ action: 'invite', org_id, role: 'editor' })\n//\n// and the function applies the product's rules in a few lines (the\n// \"workspaceGuard\" pattern):\n// 1. WHO is calling comes from `env.end_user` \u2014 the identity the platform\n// verified \u2014 never from the payload.\n// 2. WHAT they may do is asked with THEIR OWN token: `GET /v1/orgs/:id/check`\n// in end-user mode always checks the verified caller, and it reads the\n// live membership (revocation-grade, not the session snapshot).\n// 3. The write itself uses `server_jwts` \u2014 tokens NOT bound to the user,\n// limited to the trigger's `serverScopes`.\n//\n// Actions (payload.action):\n// create { slug, name } \u2192 a new workspace, the caller its owner, on the `free` plan\n// invite { org_id, role } \u2192 a single-use LINK invite (admin+); role admin|editor|viewer\n// accept { token } \u2192 the caller takes the invited seat\n// set_role { org_id, user_id, role } \u2192 change a member's role (admin+; owner changes need the owner)\n// remove { org_id, user_id } \u2192 remove a member (admin+), or leave (user_id = yourself)\n//\n// Bounds (per call): at most 4 reads and 2 writes against your own backend; no\n// fan-out, no loop. A seat check reads one members page and one invitations\n// page (both bounded by orgs.maxMembersPerOrg and the 200-row invitation list).\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ninterface Body {\n action?: string;\n org_id?: string;\n slug?: string;\n name?: string;\n role?: string;\n user_id?: string;\n token?: string;\n}\ntype Env = HttpFunctionEnvelope<Body>;\n\n/** Roles an invite or a role change may hand out. `owner` is never invited;\n * making someone an owner is a `set_role` by an owner. */\nconst INVITABLE = ['admin', 'editor', 'viewer'];\nconst ASSIGNABLE = ['owner', ...INVITABLE];\n/** Workspaces one user may own \u2014 the project-wide `orgs.maxOrgsPerTenant` is\n * shared by everyone, so one account cannot exhaust it. */\nconst MAX_OWNED_WORKSPACES = 5;\n/** Seats when the workspace has no plan row yet (= the `free` tier's quota). */\nconst FREE_SEATS = 3;\n\nconst fail = (status: number, code: string, message: string): Response =>\n Response.json({ error: { code, message } }, { status });\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 me = env.end_user;\n // A membership change always has a person behind it: a signed-in,\n // registered user (a guest session cannot own or join a workspace).\n if (!me || me.anon) return fail(401, 'sign_in_required', 'call this function with a signed-in user session');\n const userOrgs = env.scoped_jwts?.orgs; // acts AS the caller\n const orgs = env.server_jwts?.orgs; // acts as your backend\n const pay = env.server_jwts?.payments;\n if (!userOrgs || !orgs || !pay) return fail(500, 'misconfigured', 'orgs/payments scopes or serverScopes are missing');\n\n const call = async (token: string, method: string, path: string, body?: unknown) => {\n const res = await fetch(`${base}${path}`, {\n method,\n headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) },\n ...(body ? { body: JSON.stringify(body) } : {}),\n });\n const json = (await res.json().catch(() => ({}))) as { data?: Record<string, unknown>; error?: { code?: string; message?: string } };\n return { status: res.status, ok: res.ok, data: json.data ?? {}, error: json.error };\n };\n /** Pass a refusal from the backend straight through (409 last owner, 422 cap, \u2026). */\n const relay = (r: { status: number; error?: { code?: string; message?: string } | undefined }) =>\n fail(r.status, r.error?.code ?? 'upstream_error', r.error?.message ?? `request failed (${r.status})`);\n\n /** workspaceGuard: does the CALLER hold `permission` in `orgId` right now? */\n const guard = async (orgId: string, permission: string): Promise<{ allowed: boolean; role: string | null }> => {\n const r = await call(userOrgs, 'GET',\n `/v1/orgs/${encodeURIComponent(orgId)}/check?user_id=${encodeURIComponent(me.id)}&permission=${encodeURIComponent(permission)}`);\n return { allowed: r.ok && r.data.allowed === true, role: typeof r.data.role === 'string' ? r.data.role : null };\n };\n /** A member's current role in `orgId` (null = not a member). Server read. */\n const roleOf = async (orgId: string, userId: string): Promise<string | null> => {\n const r = await call(orgs, 'GET',\n `/v1/orgs/${encodeURIComponent(orgId)}/check?user_id=${encodeURIComponent(userId)}&permission=org:read`);\n return r.ok && typeof r.data.role === 'string' ? r.data.role : null;\n };\n\n const p = env.payload ?? {};\n const orgId = typeof p.org_id === 'string' ? p.org_id : '';\n\n switch (p.action) {\n case 'create': {\n if (typeof p.slug !== 'string' || typeof p.name !== 'string' || !p.name.trim()) {\n return fail(422, 'validation_failed', 'need { slug, name } (slug: a-z, 0-9 and -)');\n }\n // Counts owned workspaces among the user's 100 newest memberships (one\n // read, no paging): someone in more than 100 workspaces may get past\n // the cap. A failed read refuses the create (fail closed).\n const mine = await call(orgs, 'GET', `/v1/orgs?user_id=${encodeURIComponent(me.id)}&limit=100`);\n if (!mine.ok) return relay(mine);\n const owned = ((mine.data.orgs as Array<{ role?: string }> | undefined) ?? []).filter((o) => o.role === 'owner');\n if (owned.length >= MAX_OWNED_WORKSPACES) {\n return fail(422, 'workspace_limit', `you already own ${MAX_OWNED_WORKSPACES} workspaces`);\n }\n const made = await call(orgs, 'POST', '/v1/orgs', { slug: p.slug, name: p.name.trim(), owner_user_id: me.id });\n if (!made.ok) return relay(made);\n const newOrg = String(made.data.org_id);\n // Every workspace starts on the `free` tier: a manual, open-ended grant\n // whose subject is the WORKSPACE. Idempotent on the key, so a retry is safe.\n const grant = await call(pay, 'POST', '/v1/payments/subscriptions/grant', {\n org_id: newOrg, tier: 'free', reason: 'workspace created', idempotency_key: `free:${newOrg}`,\n });\n // The app now moves the session into it: vx.auth.sessions.switchOrg(org_id)\n return Response.json({ org_id: newOrg, slug: made.data.slug, plan: grant.ok ? 'free' : null }, { status: 201 });\n }\n\n case 'invite': {\n const role = String(p.role ?? '');\n if (!orgId || !INVITABLE.includes(role)) return fail(422, 'validation_failed', `need { org_id, role } with role ${INVITABLE.join('|')}`);\n if (!(await guard(orgId, 'members:manage')).allowed) return fail(403, 'forbidden', 'only a workspace admin or owner may invite');\n // The PLAN's seats: members + still-pending invitations must stay below it.\n const [ent, members, invites] = await Promise.all([\n call(pay, 'GET', `/v1/payments/entitlements?org_id=${encodeURIComponent(orgId)}`),\n call(orgs, 'GET', `/v1/orgs/${encodeURIComponent(orgId)}/members`),\n call(orgs, 'GET', `/v1/orgs/${encodeURIComponent(orgId)}/invitations`),\n ]);\n const quota = (ent.data.quotas as Record<string, number> | undefined)?.seats;\n const seats = typeof quota === 'number' ? quota : FREE_SEATS;\n const taken = ((members.data.members as unknown[] | undefined) ?? []).length\n + ((invites.data.invitations as Array<{ pending?: boolean }> | undefined) ?? []).filter((i) => i.pending).length;\n if (taken >= seats) return fail(402, 'seats_exhausted', `the workspace plan has ${seats} seats and all are taken or invited`);\n // A LINK invite (no email): vxil sends nothing, the token is returned\n // ONCE, and your app builds the accept link and delivers it.\n const inv = await call(orgs, 'POST', `/v1/orgs/${encodeURIComponent(orgId)}/invitations`, { role });\n if (!inv.ok) return relay(inv);\n return Response.json({ invite_id: inv.data.invite_id, role, invite_token: inv.data.invite_token }, { status: 201 });\n }\n\n case 'accept': {\n if (typeof p.token !== 'string' || !p.token) return fail(422, 'validation_failed', 'need { token }');\n // The seat goes to the VERIFIED caller, never to a user id in the payload.\n const acc = await call(orgs, 'POST', '/v1/orgs/invitations/accept', { token: p.token, user_id: me.id });\n if (!acc.ok) return relay(acc);\n // The app now moves the session into it: vx.auth.sessions.switchOrg(org_id)\n return Response.json({ org_id: acc.data.org_id, role: acc.data.role });\n }\n\n case 'set_role': {\n const role = String(p.role ?? '');\n const target = String(p.user_id ?? '');\n if (!orgId || !target || !ASSIGNABLE.includes(role)) {\n return fail(422, 'validation_failed', `need { org_id, user_id, role } with role ${ASSIGNABLE.join('|')}`);\n }\n const current = await roleOf(orgId, target);\n // Only an existing member changes role \u2014 this action never adds a seat.\n if (!current) return fail(404, 'not_found', 'that user is not a member of the workspace');\n const needs = role === 'owner' || current === 'owner' ? 'org:admin' : 'members:manage';\n if (!(await guard(orgId, needs)).allowed) {\n return fail(403, 'forbidden', needs === 'org:admin' ? 'only an owner may grant or take the owner role' : 'only a workspace admin or owner may change roles');\n }\n // An org always keeps one owner: demoting the last one is a 409 from orgs.\n const set = await call(orgs, 'POST', `/v1/orgs/${encodeURIComponent(orgId)}/members`, { user_id: target, role });\n if (!set.ok) return relay(set);\n // The member's live sessions are marked stale; their next request\n // refreshes onto the new role in about 60 s.\n return Response.json({ org_id: orgId, user_id: target, role, previous: current });\n }\n\n case 'remove': {\n const target = String(p.user_id ?? '');\n if (!orgId || !target) return fail(422, 'validation_failed', 'need { org_id, user_id }');\n if (target !== me.id) {\n const current = await roleOf(orgId, target);\n if (!current) return fail(404, 'not_found', 'that user is not a member of the workspace');\n if (!(await guard(orgId, current === 'owner' ? 'org:admin' : 'members:manage')).allowed) {\n return fail(403, 'forbidden', 'only a workspace admin (or an owner, for an owner) may remove members');\n }\n }\n const del = await call(orgs, 'DELETE', `/v1/orgs/${encodeURIComponent(orgId)}/members/${encodeURIComponent(target)}`);\n if (!del.ok) return relay(del);\n return Response.json({ org_id: orgId, removed: target });\n }\n\n default:\n return fail(422, 'validation_failed', 'action must be create | invite | accept | set_role | remove');\n }\n },\n};\n"
20935
+ }
20936
+ },
20711
20937
  {
20712
20938
  "id": "admin-console",
20713
20939
  "title": "Admin console (accounts \xB7 approvals \xB7 audit)",
@@ -20855,8 +21081,8 @@ export default defineConfig({
20855
21081
  "byoKeys": [
20856
21082
  "slack_webhook_url"
20857
21083
  ],
20858
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Alerts to Slack\" \u2014 failure events \u2192 one chat message per state crossing.\n//\n// There is no vxil \"connectors\" feature and no alert-routing engine, on purpose:\n// every feature already writes its lifecycle to your tenant's audit stream, and\n// a function can read that stream. This blueprint is DISTRIBUTION over that\n// spine \u2014 one cron function, one cms collection as its memory, one BYO webhook:\n// \u2022 cms \u2192 `alert_state`: the watermark + per-condition state (ok|stale|\n// broken|could_not_check) that makes alerts once-per-crossing\n// \u2022 functions \u2192 `alerts`: drains the audit stream past the watermark every\n// 5 minutes, matches an allow-list of failure events, posts\n// to Slack (or Discord) through the egress allowlist\n// A permanently-red condition therefore produces ONE message when it turns red,\n// ONE when it recovers, and (optionally) a reminder every REPEAT_AFTER_HOURS.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {},\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n // The function's memory. One row per alert KEY (e.g. `payments:ingest:stripe`,\n // `jobs:dead-letter:notifications.deliver`, `functions:checkout`) plus the\n // reserved `__cursor__` row that holds the audit-stream watermark.\n alert_state: {\n singular: 'alert_state',\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true }, // unique \u21D2 409 on a racing duplicate\n state: { type: 'string', indexSlot: 's2' }, // ok | stale | broken | could_not_check\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n event: { type: 'string' }, // the last audit event name seen for this key\n detail: { type: 'text' }, // last reason/error text (truncated)\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n last_event_id: { type: 'string' },\n last_alert_at: { type: 'datetime', indexSlot: 't1' },\n updated_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n },\n },\n\n functions: {\n // The whole blueprint is this one function. Cron every 5 minutes; also\n // invocable by hand (`vxil functions invoke alerts --data '{\"test\":true}'`)\n // to prove the webhook is wired without touching the watermark.\n alerts: {\n entry: './functions/alerts.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free plan: '*/15 * * * *' (its fastest function cron)\n // `audit:read` mints the function's confined control-plane token, which\n // reads the audit stream and nothing else \u2014 no API key stored as a secret.\n scopes: ['cms:read', 'cms:write', 'audit:read'],\n // One BYO secret, resolved per invocation and injected as env.secrets.<name>:\n // slack_webhook_url \u2014 the Slack incoming-webhook URL (or a Discord webhook,\n // or a Google Chat space webhook)\n secrets: ['secret:slack_webhook_url'],\n // Deny-by-default egress: only the chat host (add 'discord.com' for Discord,\n // 'chat.googleapis.com' for a Google Chat space webhook).\n egressAllow: ['hooks.slack.com'],\n },\n },\n\n secrets: {\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord / Google Chat space webhook) URL' },\n },\n});\n",
20859
- "readme": "# Alerts to Slack (ops)\n\nFailure events from your backend \u2192 **one Slack (or Discord) message per state crossing**, with a\n\"resolved\" message when the condition clears. No connectors registry, no alert-routing feature \u2014 one\ncron function that drains your tenant's **audit stream** past a watermark, an allow-list of failure\nevents it cares about, and a `cms` collection as its memory so a permanently-red condition never\nspams the channel.\n\n```bash\nmkdir my-alerts && cd my-alerts\nvxil init --template alerts-to-slack\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\n# Free plan only: set the cron to '*/15 * * * *' in vxil.config.ts now (note below)\nvxil secrets set functions/slack_webhook_url # your webhook URL (hidden prompt)\nvxil push # collection + the `alerts` cron function\nvxil functions invoke alerts --data '{\"test\":true}' # posts a test message\n```\n\n> **On the Free plan** (staging or development projects) the fastest function cron is every 15 minutes:\n> set `schedule: '*/15 * * * *'` in `vxil.config.ts` before `vxil push`, or the deploy answers\n> `402 plan_limit` (`cron_interval`). Paid plans run the 5-minute schedule as shipped.\n\n> **Plan note.** The `alerts` function deploys on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config's first push today). On a Free `production` project, `vxil push`\n> stops before it writes anything, naming the plan and the ways out: change the workload or upgrade\n> to Developer, or run `vxil push --skip-functions` to apply the collections and config without the\n> functions.\n\n**The secret** (a reference in the config, its value only ever in the encrypted secret store):\n\n| Secret | What it is | Where it comes from |\n|---|---|---|\n| `slack_webhook_url` | a Slack *incoming webhook* URL (or a Discord webhook URL, or a Google Chat *space webhook* URL) | Slack \u2192 Apps \u2192 Incoming Webhooks; Discord \u2192 channel \u2192 Integrations \u2192 Webhooks; Google Chat \u2192 space \u2192 Apps & integrations \u2192 Webhooks |\n\nThe audit stream needs no key: the function declares `audit:read`, so its scoped callback carries a\nconfined token that reads `/v1/audit` and `/v1/audit/export` and nothing else.\nFor Discord, add `'discord.com'` to `egressAllow` \u2014 the function sends both Slack's `text` and Discord's\n`content` field, so one message body works on either.\nFor Google Chat, add `'chat.googleapis.com'` to `egressAllow` \u2014 a space webhook takes `text` alone, and the\nfunction sends only that field to that host.\n\n## What it does, every 5 minutes\n\n1. **Watermark.** Reads the `__cursor__` row in `alert_state`. On the very first run it stores the\n *newest* audit id and stops \u2014 history never becomes an alert storm.\n2. **Drain.** `GET /v1/audit/export?after_id=<cursor>&limit=500` (ascending NDJSON), up to 5 pages a tick.\n3. **Match.** Each row is looked up in `RULES` (the allow-list in `functions/alerts.ts`). Anything not\n listed is ignored. A match becomes `(key, state, level)` \u2014 e.g. a `payments.webhook_event.failed`\n row with `provider: stripe, state: broken` \u2192 key `payments:ingest:stripe`, state `broken`.\n4. **Compare + decide.** One `alert_state` row per key. Only a **crossing** posts:\n - not-ok while the row said ok (or no row yet) \u2192 \u{1F534}/\u{1F7E0} alert\n - ok while the row said not-ok \u2192 \u{1F7E2} \"RESOLVED\"\n - same state again \u2192 silence (a \u23F0 reminder after `REPEAT_AFTER_HOURS`, default 24; `0` disables)\n5. **Advance** the watermark with `If-Match` on the cursor row's version. Cron deliveries are\n at-least-once; if two ticks overlap, the first to move the watermark wins, and the `unique` key plus\n `If-Match` on every state row mean a racing run gets a 409 and stands down instead of double-posting.\n\n## The events it listens for\n\nEvery name in the table below is an audit event a vxil feature writes **today**. Keys are what collapse\nrepeats: ten dead letters of the same job are one key, one row, one alert.\n\n| Audit event | Level | Key (one row each) | State |\n|---|---|---|---|\n| `payments.webhook_event.failed` | from payload (`error`) | `payments:ingest:<provider>` | from payload (`broken`) |\n| `payments.webhook.rejected` | from payload (`warn`) | `payments:webhook-rejected:<provider>` | from payload (`broken`) |\n| `payments.grant.failed` | from payload (`error`) | `payments:grant:<credit_type>:<source>` | from payload (`broken`) |\n| `payments.subscription.past_due` | warn | `payments:subscription:<end_user_id>` | broken |\n| `payments.subscription.active` | info | `payments:subscription:<end_user_id>` | **ok** \u2192 posts RESOLVED |\n| `payments.charge.disputed` | warn | `payments:disputes:<provider>` | broken |\n| `job.dead_lettered` | error | `jobs:dead-letter:<job_name>` | broken |\n| `job.dead_letter_quota_exceeded` | error | `jobs:dead-letter-quota` | broken |\n| `job.generation.failed` | error | `jobs:generation:<error_class>` | broken |\n| `functions.quarantined` | error | `functions:<name>` | broken |\n| `functions.quarantine.cleared` | info | `functions:<name>` | **ok** \u2192 posts RESOLVED |\n| `functions.deploy.denied` | warn | `functions:deploy-denied` | broken |\n| `notifications.delivery.dead_lettered` | from payload (`error`) | `notifications:dead-letter:<template_id>` | from payload (`broken`) |\n| `webhooks.delivery.dead_lettered` | from payload (`error`) | `webhooks:delivery:<subscription_id>` | from payload (`broken`) |\n| `webhooks.delivery.recovered` | from payload (`info`) | `webhooks:delivery:<subscription_id>` | **ok** \u2192 posts RESOLVED |\n| `webhooks.inbound.rejected` | from payload (`warn`) | `webhooks:inbound:<source_id>:<reason>` | from payload (`broken`) |\n| `jobs.schedule.missed` | from payload (`warn`) | `jobs:schedule:<schedule_id>` | from payload (**`stale`** \u2014 late, not broken) |\n| `jobs.schedule.recovered` | from payload (`info`) | `jobs:schedule:<schedule_id>` | **ok** \u2192 posts RESOLVED |\n| `functions.run.failed` | from payload (`error`) | `functions:run:<name>` | from payload (`broken`) |\n| `functions.run.recovered` | from payload (`info`) | `functions:run:<name>` | **ok** \u2192 posts RESOLVED |\n\n**Levels differ by event, and the table respects that.** The payments failure events and every\nlifecycle failure event (the last nine rows) carry their own `level` and `state` in the payload, so those\nrules defer to the event. The older `job.*` and `functions.quarantine*` events supply both from the rule.\n\n**Five pairs close their own alerts.** `past_due` \u2192 `active`, `quarantined` \u2192 `quarantine.cleared`,\n`webhooks.delivery.dead_lettered` \u2192 `.recovered`, `jobs.schedule.missed` \u2192 `.recovered` and\n`functions.run.failed` \u2192 `.recovered` share a key, so a recovery posts RESOLVED with no extra rule. Events with no natural \"ok\" \u2014 a dead\nletter \u2014 stay red until the reminder window, which is what `REPEAT_AFTER_HOURS` is for.\n\n**The same failure can arrive twice, on purpose.** Notification sends and outbound webhook deliveries\nride the jobs substrate, so a dead letter also arrives as `job.dead_lettered` with `job_name` set to\n`notifications.deliver` / `webhooks.deliver`. The dedicated `*.dead_lettered` events carry the\nfeature-level context (template id, subscription id, target host) that the generic job row lacks; keep\nboth rules, or drop `job.dead_lettered` if you only want the feature-level view.\n\n**Own the table.** `RULES` is data: add an event name, decide its key and level, push.\n\n## The message\n\n```\n\u{1F534} [ERROR] payments ingest (stripe) \u2192 BROKEN \u2014 signature verification failed (at 2026-09-10T06:00:12Z)\n\u{1F534} [ERROR] job dead-lettered: notifications.deliver \u2192 BROKEN (at 2026-09-10T06:03:44Z)\n\u23F0 STILL BROKEN: function quarantined: checkout (since 2026-09-09T06:01:02Z)\n\u{1F7E2} RESOLVED: payments ingest (stripe) is back to ok (was broken)\n```\n\n## What to learn from this\n\n- **The audit stream is the event spine.** Every feature writes its lifecycle there; a webhook\n subscription fans it out, and a function can drain it. Nothing here needed a new primitive.\n- **Once-per-crossing suppression needs memory \u2014 a cms collection is that memory.** `unique` on `key`\n and `If-Match` on every write make it correct under the at-least-once cron, not just usually right.\n- **Least privilege has a shape.** The function talks to cms through its scoped callback, reads the\n audit stream with an `audit:read`-only key it holds as a secret (or, once you switch it over, through\n its callback with the `audit:read` scope), and can reach exactly one external host. Rotate either\n secret without a redeploy \u2014 refs resolve per invocation.\n\n**Latency:** a failure is posted within one cron interval (5 minutes; 15 on the Free plan) of the audit row landing.\n**Pairs with:** the **payments-heartbeat** blueprint (the silence detector that emits its own crossings)\nand the **push-notifications** blueprint.\n",
21084
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Alerts to Slack\" \u2014 failure events \u2192 one chat message per state crossing.\n//\n// There is no vxil \"connectors\" feature and no alert-routing engine, on purpose:\n// every feature already writes its lifecycle to your tenant's audit stream, and\n// a function can read that stream. This blueprint is DISTRIBUTION over that\n// spine \u2014 one cron function, one cms collection as its memory, one BYO webhook:\n// \u2022 cms \u2192 `alert_state`: the watermark + per-condition state (ok|stale|\n// broken|could_not_check) that makes alerts once-per-crossing\n// \u2022 functions \u2192 `alerts`: drains the audit stream past the watermark every\n// 5 minutes, matches an allow-list of failure events, posts\n// to Slack (or Discord) through the egress allowlist\n// A permanently-red condition therefore produces ONE message when it turns red,\n// ONE when it recovers, and (optionally) a reminder every REPEAT_AFTER_HOURS.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {},\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n // The function's memory. One row per alert KEY (e.g. `payments:ingest:stripe`,\n // `jobs:dead-letter:notifications.deliver`, `functions:checkout`) plus the\n // reserved `__cursor__` row that holds the audit-stream watermark.\n alert_state: {\n singular: 'alert_state',\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true }, // unique \u21D2 409 on a racing duplicate\n state: { type: 'string', indexSlot: 's2' }, // ok | stale | broken | could_not_check\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n event: { type: 'string' }, // the last audit event name seen for this key\n detail: { type: 'text' }, // last reason/error text (truncated)\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n last_event_id: { type: 'string' },\n last_alert_at: { type: 'datetime', indexSlot: 't1' },\n updated_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n },\n },\n\n functions: {\n // The whole blueprint is this one function. Cron every 5 minutes; also\n // invocable by hand (`vxil functions invoke alerts --data '{\"test\":true}'`)\n // to prove the webhook is wired without touching the watermark.\n alerts: {\n entry: './functions/alerts.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free staging/development: deploys as is; '*/15 * * * *' keeps it inside the 500,000 CPU-ms day\n // `audit:read` mints the function's confined control-plane token, which\n // reads the audit stream and nothing else \u2014 no API key stored as a secret.\n scopes: ['cms:read', 'cms:write', 'audit:read'],\n // One BYO secret, resolved per invocation and injected as env.secrets.<name>:\n // slack_webhook_url \u2014 the Slack incoming-webhook URL (or a Discord webhook,\n // or a Google Chat space webhook)\n secrets: ['secret:slack_webhook_url'],\n // Deny-by-default egress: only the chat host (add 'discord.com' for Discord,\n // 'chat.googleapis.com' for a Google Chat space webhook).\n egressAllow: ['hooks.slack.com'],\n },\n },\n\n secrets: {\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord / Google Chat space webhook) URL' },\n },\n});\n",
21085
+ "readme": "# Alerts to Slack (ops)\n\nFailure events from your backend \u2192 **one Slack (or Discord) message per state crossing**, with a\n\"resolved\" message when the condition clears. No connectors registry, no alert-routing feature \u2014 one\ncron function that drains your tenant's **audit stream** past a watermark, an allow-list of failure\nevents it cares about, and a `cms` collection as its memory so a permanently-red condition never\nspams the channel.\n\n```bash\nmkdir my-alerts && cd my-alerts\nvxil init --template alerts-to-slack\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\n# Free plan, optional: set the cron to '*/15 * * * *' to keep it inside the daily CPU budget (note below)\nvxil secrets set functions/slack_webhook_url # your webhook URL (hidden prompt)\nvxil push # collection + the `alerts` cron function\nvxil functions invoke alerts --data '{\"test\":true}' # posts a test message\n```\n\n> **On the Free plan** (staging or development projects) the 5-minute cron deploys as shipped, but every call\n> counts its full 2,000 ms CPU limit against the project's 500,000 CPU-ms a day, so `*/5` alone spends the day's\n> budget by late evening UTC. Set `schedule: '*/15 * * * *'` to keep `alerts` running all day.\n> Paid plans run the 5-minute schedule as shipped.\n\n> **Plan note.** The `alerts` function deploys on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config's first push today). On a Free `production` project, `vxil push`\n> stops before it writes anything, naming the plan and the ways out: change the workload or upgrade\n> to Developer, or run `vxil push --skip-functions` to apply the collections and config without the\n> functions.\n\n**The secret** (a reference in the config, its value only ever in the encrypted secret store):\n\n| Secret | What it is | Where it comes from |\n|---|---|---|\n| `slack_webhook_url` | a Slack *incoming webhook* URL (or a Discord webhook URL, or a Google Chat *space webhook* URL) | Slack \u2192 Apps \u2192 Incoming Webhooks; Discord \u2192 channel \u2192 Integrations \u2192 Webhooks; Google Chat \u2192 space \u2192 Apps & integrations \u2192 Webhooks |\n\nThe audit stream needs no key: the function declares `audit:read`, so its scoped callback carries a\nconfined token that reads `/v1/audit` and `/v1/audit/export` and nothing else.\nFor Discord, add `'discord.com'` to `egressAllow` \u2014 the function sends both Slack's `text` and Discord's\n`content` field, so one message body works on either.\nFor Google Chat, add `'chat.googleapis.com'` to `egressAllow` \u2014 a space webhook takes `text` alone, and the\nfunction sends only that field to that host.\n\n## What it does, every 5 minutes\n\n1. **Watermark.** Reads the `__cursor__` row in `alert_state`. On the very first run it stores the\n *newest* audit id and stops \u2014 history never becomes an alert storm.\n2. **Drain.** `GET /v1/audit/export?after_id=<cursor>&limit=500` (ascending NDJSON), up to 5 pages a tick.\n3. **Match.** Each row is looked up in `RULES` (the allow-list in `functions/alerts.ts`). Anything not\n listed is ignored. A match becomes `(key, state, level)` \u2014 e.g. a `payments.webhook_event.failed`\n row with `provider: stripe, state: broken` \u2192 key `payments:ingest:stripe`, state `broken`.\n4. **Compare + decide.** One `alert_state` row per key. Only a **crossing** posts:\n - not-ok while the row said ok (or no row yet) \u2192 \u{1F534}/\u{1F7E0} alert\n - ok while the row said not-ok \u2192 \u{1F7E2} \"RESOLVED\"\n - same state again \u2192 silence (a \u23F0 reminder after `REPEAT_AFTER_HOURS`, default 24; `0` disables)\n5. **Advance** the watermark with `If-Match` on the cursor row's version. Cron deliveries are\n at-least-once; if two ticks overlap, the first to move the watermark wins, and the `unique` key plus\n `If-Match` on every state row mean a racing run gets a 409 and stands down instead of double-posting.\n\n## The events it listens for\n\nEvery name in the table below is an audit event a vxil feature writes **today**. Keys are what collapse\nrepeats: ten dead letters of the same job are one key, one row, one alert.\n\n| Audit event | Level | Key (one row each) | State |\n|---|---|---|---|\n| `payments.webhook_event.failed` | from payload (`error`) | `payments:ingest:<provider>` | from payload (`broken`) |\n| `payments.webhook.rejected` | from payload (`warn`) | `payments:webhook-rejected:<provider>` | from payload (`broken`) |\n| `payments.grant.failed` | from payload (`error`) | `payments:grant:<credit_type>:<source>` | from payload (`broken`) |\n| `payments.subscription.past_due` | warn | `payments:subscription:<end_user_id>` | broken |\n| `payments.subscription.active` | info | `payments:subscription:<end_user_id>` | **ok** \u2192 posts RESOLVED |\n| `payments.charge.disputed` | warn | `payments:disputes:<provider>` | broken |\n| `job.dead_lettered` | error | `jobs:dead-letter:<job_name>` | broken |\n| `job.dead_letter_quota_exceeded` | error | `jobs:dead-letter-quota` | broken |\n| `job.generation.failed` | error | `jobs:generation:<error_class>` | broken |\n| `functions.quarantined` | error | `functions:<name>` | broken |\n| `functions.quarantine.cleared` | info | `functions:<name>` | **ok** \u2192 posts RESOLVED |\n| `functions.deploy.denied` | warn | `functions:deploy-denied` | broken |\n| `notifications.delivery.dead_lettered` | from payload (`error`) | `notifications:dead-letter:<template_id>` | from payload (`broken`) |\n| `webhooks.delivery.dead_lettered` | from payload (`error`) | `webhooks:delivery:<subscription_id>` | from payload (`broken`) |\n| `webhooks.delivery.recovered` | from payload (`info`) | `webhooks:delivery:<subscription_id>` | **ok** \u2192 posts RESOLVED |\n| `webhooks.inbound.rejected` | from payload (`warn`) | `webhooks:inbound:<source_id>:<reason>` | from payload (`broken`) |\n| `jobs.schedule.missed` | from payload (`warn`) | `jobs:schedule:<schedule_id>` | from payload (**`stale`** \u2014 late, not broken) |\n| `jobs.schedule.recovered` | from payload (`info`) | `jobs:schedule:<schedule_id>` | **ok** \u2192 posts RESOLVED |\n| `functions.run.failed` | from payload (`error`) | `functions:run:<name>` | from payload (`broken`) |\n| `functions.run.recovered` | from payload (`info`) | `functions:run:<name>` | **ok** \u2192 posts RESOLVED |\n\n**Levels differ by event, and the table respects that.** The payments failure events and every\nlifecycle failure event (the last nine rows) carry their own `level` and `state` in the payload, so those\nrules defer to the event. The older `job.*` and `functions.quarantine*` events supply both from the rule.\n\n**Five pairs close their own alerts.** `past_due` \u2192 `active`, `quarantined` \u2192 `quarantine.cleared`,\n`webhooks.delivery.dead_lettered` \u2192 `.recovered`, `jobs.schedule.missed` \u2192 `.recovered` and\n`functions.run.failed` \u2192 `.recovered` share a key, so a recovery posts RESOLVED with no extra rule. Events with no natural \"ok\" \u2014 a dead\nletter \u2014 stay red until the reminder window, which is what `REPEAT_AFTER_HOURS` is for.\n\n**The same failure can arrive twice, on purpose.** Notification sends and outbound webhook deliveries\nride the jobs substrate, so a dead letter also arrives as `job.dead_lettered` with `job_name` set to\n`notifications.deliver` / `webhooks.deliver`. The dedicated `*.dead_lettered` events carry the\nfeature-level context (template id, subscription id, target host) that the generic job row lacks; keep\nboth rules, or drop `job.dead_lettered` if you only want the feature-level view.\n\n**Own the table.** `RULES` is data: add an event name, decide its key and level, push.\n\n## The message\n\n```\n\u{1F534} [ERROR] payments ingest (stripe) \u2192 BROKEN \u2014 signature verification failed (at 2026-09-10T06:00:12Z)\n\u{1F534} [ERROR] job dead-lettered: notifications.deliver \u2192 BROKEN (at 2026-09-10T06:03:44Z)\n\u23F0 STILL BROKEN: function quarantined: checkout (since 2026-09-09T06:01:02Z)\n\u{1F7E2} RESOLVED: payments ingest (stripe) is back to ok (was broken)\n```\n\n## What to learn from this\n\n- **The audit stream is the event spine.** Every feature writes its lifecycle there; a webhook\n subscription fans it out, and a function can drain it. Nothing here needed a new primitive.\n- **Once-per-crossing suppression needs memory \u2014 a cms collection is that memory.** `unique` on `key`\n and `If-Match` on every write make it correct under the at-least-once cron, not just usually right.\n- **Least privilege has a shape.** The function talks to cms through its scoped callback, reads the\n audit stream with an `audit:read`-only key it holds as a secret (or, once you switch it over, through\n its callback with the `audit:read` scope), and can reach exactly one external host. Rotate either\n secret without a redeploy \u2014 refs resolve per invocation.\n\n**Latency:** a failure is posted within one cron interval (5 minutes, or 15 if you relaxed it on the Free plan) of the audit row landing.\n**Pairs with:** the **payments-heartbeat** blueprint (the silence detector that emits its own crossings)\nand the **push-notifications** blueprint.\n",
20860
21086
  "functions": {
20861
21087
  "alerts.ts": "// alerts.ts \u2014 FAILURE EVENTS \u2192 ONE CHAT MESSAGE PER STATE CROSSING (a vxil function).\n//\n// Trigger: cron `*/5 * * * *`. Each run:\n// 1. reads the watermark (the `__cursor__` row in `alert_state`; first run = \"now\",\n// so history never produces an alert storm),\n// 2. drains the tenant audit stream past it \u2014 `GET /v1/audit/export?after_id=\u2026`\n// (ascending, NDJSON) with the function's confined control-plane token\n// (the declared `audit:read` scope; no stored key),\n// 3. matches each row against RULES (the allow-list below) and turns it into a\n// (key, state, level) \u2014 e.g. `payments:ingest:stripe` \u2192 `broken`,\n// 4. compares with the stored state for that key (cms, one row per key, `unique`\n// key \u21D2 a racing run gets a 409 and stands down) and posts ONLY on a crossing:\n// ok\u2192red = alert, red\u2192ok = \"resolved\", red\u2192red = silence (or a reminder after\n// REPEAT_AFTER_HOURS),\n// 5. advances the watermark (If-Match on the cursor row's version \u2014 a concurrent\n// run that already advanced it wins; jobs may deliver a cron tick twice).\n//\n// Manual check: `vxil functions invoke alerts --data '{\"test\":true}'` posts a test\n// message and touches nothing else.\n//\n// Tune the constants; own the RULES table \u2014 it is data, not a routing engine.\n\n// cron-walk: persisted-cursor \u2014 the audit watermark lives in the `__cursor__` row and advances with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst REPEAT_AFTER_HOURS = 24; // remind about a still-red condition this often; 0 = never\nconst MAX_PAGES_PER_RUN = 5; // \xD7 500 audit rows \u2014 bounds one cron tick\nconst MAX_POSTS_PER_RUN = 20; // a burst of distinct failures collapses into one summary line\nconst DETAIL_MAX = 240;\n\ntype Level = 'info' | 'warn' | 'error';\ntype State = 'ok' | 'stale' | 'broken' | 'could_not_check';\ntype Payload = Record<string, unknown>;\n\ninterface Rule {\n /** A literal level, or 'payload' to take `payload.level` (the payments failure\n * events carry one; the jobs/functions ones do not). */\n level: Level | 'payload';\n key: (p: Payload) => string; // one state row per key \u2014 this is what suppresses repeats\n title: (p: Payload) => string;\n /** A literal state, or omitted to take `payload.state` (falling back to 'broken'). */\n state?: State;\n}\n\nconst str = (v: unknown, fallback = 'unknown') => (typeof v === 'string' && v ? v : fallback);\n\n/** THE ALLOW-LIST. Every name below is an audit event a vxil feature writes;\n * anything not listed is ignored by the drain. The KEY is what collapses\n * repeats: ten dead letters of the same job are one key, one row, one alert.\n *\n * Every name below is emitted today (2026-09-10). A rule for an event nobody\n * emits would simply never match \u2014 so if you add one, verify the emitter first.\n */\nconst RULES: Record<string, Rule> = {\n // \u2500\u2500 payments \u2014 the money path. A silent failure here is lost revenue. \u2500\u2500\u2500\u2500\u2500\u2500\n // These three carry `level` (info|warn|error) and `state` (ok|stale|broken|\n // could_not_check) in their payload, so the rule defers to the event.\n 'payments.webhook_event.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:ingest:${str(p.provider, 'all')}`,\n title: (p) => `payments ingest (${str(p.provider, 'all providers')})`,\n },\n 'payments.webhook.rejected': {\n level: 'payload', // emitted as warn/broken\n key: (p) => `payments:webhook-rejected:${str(p.provider)}`,\n title: (p) => `payments webhook rejected (${str(p.provider)})`,\n },\n // NOTE: grant.failed carries NO `provider` \u2014 it is keyed on the credit type\n // and the grant source instead (end_user_id/credit_type/amount/grant_key/\n // source/error). Keying on a field an event does not carry would collapse\n // every provider into one bucket named \"unknown\".\n 'payments.grant.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:grant:${str(p.credit_type, 'credits')}:${str(p.source, 'webhook')}`,\n title: (p) => `entitlement grant failed (${str(p.credit_type, 'credits')} via ${str(p.source, 'webhook')})`,\n },\n // Dunning. `past_due` is the red crossing and `active` is its RESOLVED twin \u2014\n // the same key, so a recovered subscription closes its own alert. Both are\n // emitted at level info, so the rule forces the level it wants.\n 'payments.subscription.past_due': {\n level: 'warn',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'broken',\n },\n 'payments.subscription.active': {\n level: 'info',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'ok',\n },\n // A dispute is a chargeback in progress \u2014 money already collected, now at risk.\n 'payments.charge.disputed': {\n level: 'warn',\n key: (p) => `payments:disputes:${str(p.provider)}`,\n title: (p) => `charge disputed (${str(p.provider)})`,\n state: 'broken',\n },\n\n // \u2500\u2500 jobs \u2014 the delivery substrate. Notification and outbound-webhook sends\n // ride jobs, so THEIR dead letters arrive here too, under `job_name`\n // ('notifications.deliver', 'webhooks.deliver', 'fn-cron:<name>', \u2026).\n // These payloads carry run_id/attempt/job_name and NO level or state, so\n // the rule supplies both. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n 'job.dead_lettered': {\n level: 'error',\n key: (p) => `jobs:dead-letter:${str(p.job_name)}`,\n title: (p) => `job dead-lettered: ${str(p.job_name)}`,\n state: 'broken',\n },\n 'job.dead_letter_quota_exceeded': {\n level: 'error',\n key: () => 'jobs:dead-letter-quota',\n title: () => 'daily dead-letter quota exceeded',\n state: 'broken',\n },\n 'job.generation.failed': {\n level: 'error',\n key: (p) => `jobs:generation:${str(p.error_class, 'failed')}`,\n title: (p) => `generation job failed (${str(p.error_class, 'unclassified')})`,\n state: 'broken',\n },\n\n // \u2500\u2500 functions \u2014 the breaker. Consecutive failures quarantine a function;\n // clearing the quarantine is the ok crossing on the SAME key, so this pair\n // produces exactly one \u{1F534} and one \u{1F7E2}. Payload: name (+ consecutive_failures).\n 'functions.quarantined': {\n level: 'error',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'broken',\n },\n 'functions.quarantine.cleared': {\n level: 'info',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'ok',\n },\n 'functions.deploy.denied': {\n level: 'warn',\n key: () => 'functions:deploy-denied',\n title: (p) => `function deploy denied (needs the ${str(p.required_tier)} tier)`,\n state: 'broken',\n },\n\n // \u2500\u2500 The lifecycle failure events (live since 2026-09-10). Every one of these\n // carries `level` + `state` in its payload (the platform's failure-event\n // vocabulary gate enforces it), so the rules defer to the event. Each\n // failure/recovery pair shares a key, so a recovery posts RESOLVED. \u2500\u2500\u2500\u2500\u2500\u2500\n 'notifications.delivery.dead_lettered': {\n level: 'payload', // emitted as error/broken; payload: delivery_id, template_id, error_class, attempts\n key: (p) => `notifications:dead-letter:${str(p.template_id, 'all')}`,\n title: (p) => `notification delivery dead-lettered (${str(p.template_id, 'all templates')})`,\n },\n 'webhooks.delivery.dead_lettered': {\n level: 'payload', // error/broken; payload: subscription_id, target_host, attempts, error_class\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.delivery.recovered': {\n level: 'payload', // info/ok \u2014 same key as the dead-letter \u2192 RESOLVED\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.inbound.rejected': {\n level: 'payload', // warn/broken; payload: source_id, provider, reason, fingerprint (never the body)\n key: (p) => `webhooks:inbound:${str(p.source_id)}:${str(p.reason)}`,\n title: (p) => `inbound webhook rejected (${str(p.provider, 'source')} ${str(p.source_id)}: ${str(p.reason)})`,\n },\n 'jobs.schedule.missed': {\n level: 'payload', // warn/STALE \u2014 a late tick is stale, not broken; payload: schedule_id, job_name, late_seconds\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n 'jobs.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED on the next on-time fire\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n // A function's cron trigger that produced NO run for 2\xD7 its interval \u2014 the\n // platform's silent-schedule watch (error/stale), and the recovery on the same\n // key. Payload: function, schedule_id, cron, last_run_at, expected_by.\n 'functions.schedule.missed': {\n level: 'payload',\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))} (last run ${str(p.last_run_at, 'never')})`,\n },\n 'functions.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED when a run appears again\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))}`,\n },\n 'functions.run.failed': {\n level: 'payload', // error/broken; payload: name, trigger, run_id, error_class\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n 'functions.run.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n};\n\n// the schedule tick, or a hand invoke (`POST /v1/fn/alerts`) carrying `{ test: true }`\ntype Envelope = CronFunctionEnvelope | HttpFunctionEnvelope<{ test?: boolean }>;\ninterface AuditRow { id: string | number; event: string; payload?: Payload; created_at?: string }\ninterface StateData {\n key: string; state?: State; level?: Level; event?: string; detail?: string;\n cursor?: string; last_event_id?: string; last_alert_at?: string; updated_at?: string;\n}\ninterface Item { item_id: string; version?: number; data: StateData }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const hook = env.secrets?.slack_webhook_url;\n const readKey = env.scoped_jwts?.['control-plane'];\n if (!cms || !readKey) return Response.json({ error: 'missing cms or audit:read scope' }, { status: 403 });\n if (!hook) return Response.json({ error: 'missing secrets' }, { status: 409 });\n\n // Manual wiring check \u2014 posts, touches nothing else.\n if (env.payload?.test) {\n const r = await post(hook, `\u{1F7E2} [INFO] alerts-to-slack is wired (test message)`);\n return Response.json({ test: true, posted: r.ok, status: r.status });\n }\n\n const store = new Store(base, cms);\n\n // 1. the watermark (first run: start at the newest audit id \u2014 no history storm)\n const cursorRow = await store.byKey('__cursor__');\n if (!cursorRow) {\n const head = await newestAuditId(base, readKey);\n await store.create({ key: '__cursor__', cursor: head, updated_at: iso() });\n return Response.json({ initialized: true, cursor: head });\n }\n let cursor = cursorRow.data.cursor ?? '0';\n const cursorVersion = cursorRow.version;\n\n // 2\u20134. drain + evaluate\n const posts: string[] = [];\n let scanned = 0;\n let matched = 0;\n for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {\n const { rows, next } = await exportAudit(base, readKey, cursor);\n for (const row of rows) {\n scanned++;\n const rule = RULES[row.event];\n if (rule) {\n matched++;\n const msg = await evaluate(store, rule, row);\n if (msg) posts.push(msg);\n }\n cursor = String(row.id);\n }\n if (!next) break;\n cursor = next;\n }\n\n // deliver (bounded)\n let posted = 0;\n const lines = posts.slice(0, MAX_POSTS_PER_RUN);\n if (posts.length > MAX_POSTS_PER_RUN) lines.push(`\u2026 and ${posts.length - MAX_POSTS_PER_RUN} more state changes this tick`);\n for (const text of lines) {\n const r = await post(hook, text);\n if (r.ok) posted++;\n }\n\n // 5. advance the watermark \u2014 If-Match: a concurrent run that already moved it wins\n const advanced = await store.patch(cursorRow.item_id, cursorVersion, { cursor, updated_at: iso() });\n\n return Response.json({ scanned, matched, alerts: posts.length, posted, cursor, advanced });\n },\n};\n\n/** Decide, for one matched audit row, whether a message is due; persist the state. */\nasync function evaluate(store: Store, rule: Rule, row: AuditRow): Promise<string | null> {\n const p = row.payload ?? {};\n const key = rule.key(p);\n const next: State = rule.state ?? (isState(p.state) ? p.state : 'broken');\n const level: Level = rule.level === 'payload' ? (isLevel(p.level) ? p.level : 'error') : rule.level;\n const title = rule.title(p);\n const detail = str(p.reason ?? p.error ?? p.message ?? p.last_error_msg, '').slice(0, DETAIL_MAX);\n const now = iso();\n const eventId = String(row.id);\n const base: StateData = { key, state: next, level, event: row.event, detail, last_event_id: eventId, updated_at: now };\n\n const current = await store.byKey(key);\n if (!current) {\n // First sighting. A green first sighting is remembered silently \u2014 nothing was red.\n if (next === 'ok') { await store.create(base); return null; }\n const created = await store.create({ ...base, last_alert_at: now });\n return created ? format(level, next, title, detail, row.created_at) : null; // 409 = a racing run owns it\n }\n const prev = current.data.state ?? 'ok';\n if (prev !== next) {\n // A CROSSING. If-Match: exactly one concurrent run wins the transition.\n const ok = await store.patch(current.item_id, current.version, { ...base, last_alert_at: now });\n if (!ok) return null;\n return next === 'ok'\n ? `\u{1F7E2} RESOLVED: ${title} is back to ok (was ${prev})`\n : format(level, next, title, detail, row.created_at);\n }\n // Same state. Red stays quiet \u2014 unless a reminder is due.\n if (next !== 'ok' && REPEAT_AFTER_HOURS > 0) {\n const last = Date.parse(current.data.last_alert_at ?? '') || 0;\n if (Date.now() - last >= REPEAT_AFTER_HOURS * 3600e3) {\n const ok = await store.patch(current.item_id, current.version, { ...base, last_alert_at: now });\n return ok ? `\u23F0 STILL ${next.toUpperCase()}: ${title} (since ${current.data.last_alert_at ?? '?'})` : null;\n }\n }\n await store.patch(current.item_id, current.version, base); // keep detail/last_event_id fresh, no post\n return null;\n}\n\nfunction format(level: Level, state: State, title: string, detail: string, at?: string): string {\n const dot = level === 'error' ? '\u{1F534}' : level === 'warn' ? '\u{1F7E0}' : '\u{1F535}';\n return `${dot} [${level.toUpperCase()}] ${title} \u2192 ${state.toUpperCase()}${detail ? ` \u2014 ${detail}` : ''}${at ? ` (at ${at})` : ''}`;\n}\n\n// \u2500\u2500 the audit stream (ascending NDJSON export; `x-vxil-next-after-id` continues it) \u2500\u2500\nasync function exportAudit(base: string, key: string, afterId: string): Promise<{ rows: AuditRow[]; next: string | null }> {\n const res = await fetch(`${base}/v1/audit/export?after_id=${encodeURIComponent(afterId)}&limit=500`, {\n headers: { authorization: `Bearer ${key}` },\n });\n if (!res.ok) throw new Error(`audit export ${res.status}`);\n const rows = (await res.text()).split('\\n').filter(Boolean).map((l) => JSON.parse(l) as AuditRow);\n return { rows, next: res.headers.get('x-vxil-next-after-id') };\n}\nasync function newestAuditId(base: string, key: string): Promise<string> {\n const res = await fetch(`${base}/v1/audit?limit=1`, { headers: { authorization: `Bearer ${key}` } });\n if (!res.ok) throw new Error(`audit list ${res.status}`);\n const body = (await res.json()) as { data?: { events?: AuditRow[] } };\n return String(body.data?.events?.[0]?.id ?? '0');\n}\n\n// \u2500\u2500 the cms state store (the REST envelope: { data: { items:[{item_id, version, data}] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() { return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' }; }\n async byKey(key: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${this.base}/v1/cms/items/alert_state?filter=${filter}&limit=1`, { headers: this.h() });\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 (unique key already claimed by a concurrent run) */\n async create(data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/alert_state`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ data, status: 'published' }),\n });\n return res.ok;\n }\n /** false on 409 version_conflict (another run transitioned this key first) */\n async patch(itemId: string, version: number | undefined, data: Partial<StateData>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/alert_state/${itemId}`, {\n method: 'PATCH',\n headers: { ...this.h(), ...(version !== undefined ? { 'if-match': String(version) } : {}) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n\n// \u2500\u2500 the chat webhook: `text` is Slack's field, `content` is Discord's \u2014 send both,\n// EXCEPT to a Google Chat space webhook (chat.googleapis.com), which takes\n// `{ text }` alone \u2014 an unknown field can be rejected there, so it is dropped.\nasync function post(url: string, text: string): Promise<{ ok: boolean; status: number }> {\n let host = '';\n try { host = new URL(url).hostname; } catch { /* unparseable \u2192 the generic body below */ }\n const body = host === 'chat.googleapis.com' ? { text } : { text, content: text };\n const res = await fetch(url, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n }).catch(() => null);\n return { ok: Boolean(res?.ok), status: res?.status ?? 0 };\n}\n\nconst iso = () => new Date().toISOString();\nconst isState = (v: unknown): v is State => v === 'ok' || v === 'stale' || v === 'broken' || v === 'could_not_check';\nconst isLevel = (v: unknown): v is Level => v === 'info' || v === 'warn' || v === 'error';\n"
20862
21088
  }
@@ -20952,7 +21178,7 @@ export default defineConfig({
20952
21178
  "oidc_client_secret"
20953
21179
  ],
20954
21180
  "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// \"Service monitor\" \u2014 uptime and health checks for your team's PUBLIC\n// endpoints, dead-man's-switch heartbeats from your own jobs, outages that open\n// and close once per state crossing, the on-call shift paged from data, and a\n// keyless public status page. Declared end-to-end in ONE typed file.\n//\n// \u2022 auth \u2192 staff sign in through your identity provider (OIDC);\n// password and magic-link sign-in are off \u2014 staff only\n// \u2022 orgs \u2192 one team org; the roles `oncall` (outages.manage) and\n// `monitor-admin` (checks.manage) are rows you add once\n// \u2022 cms \u2192 checks \xB7 uptime_hourly \xB7 outages \xB7 oncall_shifts (private)\n// and status_components \xB7 outage_updates (\u2B22 PUBLIC: only\n// their PUBLISHED rows are readable with no key)\n// \u2022 notifications \u2192 the page: e-mail + the in-app inbox of the shift\n// \u2022 functions \u2192 probe (cron) \xB7 heartbeat (http) \xB7 outage-action (http) \xB7\n// daily-report (cron)\n//\n// There is no monitoring engine, no escalation engine and no connector here:\n// a probe is a cron function behind a deny-by-default egress allowlist, an\n// outage is a row whose creation is guarded, a page is a read of who is on\n// shift right now, and escalation is one more filter in the same cron.\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 Staff-only sign-in \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // The OIDC issuer is your company's identity provider; `allowedDomains`\n // fences sign-in to your own e-mail domain (no e-mail \u21D2 refused). Password\n // and magic-link sign-in are switched off, so nobody outside the IdP can\n // create an account. Store the client secret once:\n // printf '%s' \"$OIDC_SECRET\" | vxil secrets set auth/oidc_client_secret\n auth: {\n methods: { emailPassword: false, magicLink: false },\n providers: {\n oidc: {\n issuer: 'https://login.example-idp.com', // https, no query/fragment\n clientId: 'vxil-service-monitor', // not a secret: it rides every authorize URL\n clientSecretRef: 'secret:oidc_client_secret', // the `secrets` block below\n scopes: ['email', 'profile'], // `openid` is always added\n allowedDomains: ['example.com'], // fail-closed domain fence\n },\n },\n // The member's org role rides the session (a snapshot, refreshed with it).\n // The functions below do NOT trust it: they re-check the permission live.\n orgClaims: { enabled: true },\n session: { ttlMinutes: 60, refreshTtlDays: 7 },\n security: {\n // every sign-in return URL must be your own admin app\n allowedRedirectOrigins: ['https://status-admin.example.com'],\n },\n },\n\n // \u2500\u2500 One team org, three roles \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // `viewer` is built in (reads only). `oncall` and `monitor-admin` are\n // CUSTOM roles, declared here and created / updated by `vxil push`.\n // `owner` holds every permission.\n orgs: {\n enabled: true, maxOrgsPerTenant: 5, maxMembersPerOrg: 500, invitationTtlHours: 72,\n roles: [\n { role_key: 'oncall', name: 'On call', permissions: ['outages.manage'] },\n { role_key: 'monitor-admin', name: 'Monitor admin', permissions: ['outages.manage', 'checks.manage'] },\n ],\n },\n\n cms: {\n // Draft \u2192 publish is ON, and it matters for exactly one collection:\n // `outage_updates`. A staff note is written as a DRAFT and is never\n // public; only an update the team chooses to publish reaches the status\n // page. Every other write in this blueprint passes `status` explicitly.\n draftPublish: true,\n hooks: {\n // The OUTAGE LIFECYCLE, enforced in the same write: open \u2192 acked \u2192\n // resolved, open \u2192 resolved; `resolved` is terminal.\n outage_stage: {\n collection: 'outages',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.state == before.state'\n + \" || (before.state == 'open' && (item.state == 'acked' || item.state == 'resolved'))\"\n + \" || (before.state == 'acked' && item.state == 'resolved')\",\n message: 'illegal outage state transition (open \u2192 acked \u2192 resolved)',\n },\n // ONE uptime row per check per hour: the composed key is DERIVED\n // server-side, and `point_key` is unique \u2014 a racing second create is\n // a 409, never a second row. (Hooks do not run on $inc, which is fine:\n // an increment never changes check or bucket.)\n uptime_key: {\n collection: 'uptime_hourly',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'point_key',\n expr: \"concat(item.check, '|', item.bucket)\",\n },\n check_kind: {\n collection: 'checks',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.kind == 'http' || item.kind == 'keyword' || item.kind == 'heartbeat'\",\n message: 'kind must be http, keyword or heartbeat',\n },\n // A probed URL is https, always. A heartbeat check has no URL.\n check_url: {\n collection: 'checks',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.kind == 'heartbeat' || startsWith(item.url, 'https://')\",\n message: 'an http or keyword check needs an https:// url',\n },\n },\n // OPTIONAL live view for your admin screen: every outage write is pushed to\n // a realtime channel (enable the realtime feature too). Safe with\n // `payload: 'full'` because no `outages` field declares readRoles.\n // cdc: {\n // outages_live: { collection: 'outages', channel: 'monitor:outages', payload: 'full' },\n // },\n },\n\n // The page goes out as the shipped `transactional` message (a subject + a\n // paragraph + a button to the outage) on e-mail AND the in-app inbox.\n notifications: {\n provider: 'mock', // switch to your e-mail provider before production\n fromEmail: 'monitor@example.com',\n fromName: 'Service monitor',\n inboxEnabled: true,\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 // What to watch. One row per endpoint or heartbeat.\n checks: {\n singular: 'check',\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true }, // 'api-health'\n kind: { type: 'string', required: true, indexSlot: 's2' }, // http | keyword | heartbeat (hook-checked)\n component: { type: 'string', indexSlot: 's3' }, // a status_components key\n state: { type: 'string', indexSlot: 's4', validation: { enum: ['up', 'degraded', 'down', 'paused'] } },\n consecutive_failures: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_latency_ms: { type: 'float', indexSlot: 'n2' },\n next_due_at: { type: 'datetime', indexSlot: 't1' }, // the probe's work-queue filter\n last_checked_at: { type: 'datetime', indexSlot: 't2' },\n // \u2500\u2500 unslotted \u2500\u2500\n name: { type: 'string', validation: { max: 120 } },\n url: { type: 'string', validation: { max: 2000 } }, // https only (check_url hook)\n expect_status: { type: 'int', validation: { min: 100, max: 599 } }, // default 200\n expect_contains: { type: 'string', validation: { max: 200 } }, // keyword checks\n send_probe_token: { type: 'bool' }, // send `x-probe-token: <probe_token secret>`\n timeout_ms: { type: 'int', validation: { min: 1000, max: 15000 } }, // default 10000\n degraded_ms: { type: 'int', validation: { min: 1 } }, // slower than this \u21D2 degraded\n interval_min: { type: 'int', validation: { min: 1, max: 1440 } }, // default 1\n failures_to_down: { type: 'int', validation: { min: 1, max: 10 } }, // default 2\n enabled: { type: 'bool', indexed: true },\n heartbeat_grace_min: { type: 'int', validation: { min: 1, max: 10080 } }, // heartbeat checks\n last_heartbeat_at: { type: 'datetime' },\n hour_bucket: { type: 'string' }, // 'YYYY-MM-DDTHH' of hour_row\n hour_row: { type: 'string' }, // the current uptime_hourly item id\n hour_max: { type: 'float' }, // the slowest sample this hour (drives latency_max)\n open_outage: { type: 'string' }, // the outage this down-crossing opened\n last_error: { type: 'text' },\n },\n },\n\n // Hourly counters: ok / total per check per hour, incremented atomically.\n uptime_hourly: {\n singular: 'uptime_point',\n fields: {\n check: { type: 'string', required: true, indexSlot: 's1' }, // a checks key\n bucket: { type: 'string', required: true, indexSlot: 's2' }, // 'YYYY-MM-DDTHH' (UTC)\n point_key: { type: 'string', indexSlot: 's3', unique: true }, // derived: check|bucket\n ok: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n total: { type: 'int', indexSlot: 'n2', validation: { min: 0 } },\n ts: { type: 'datetime', indexSlot: 't1' }, // the hour's start \u2014 the report window field\n latency_sum: { type: 'float' },\n latency_max: { type: 'float' },\n },\n },\n\n // One row per down-crossing. Created under a lock + guard, so a check can\n // never have two open outages, however often a tick is re-delivered.\n outages: {\n singular: 'outage',\n fields: {\n check: { type: 'string', required: true, indexSlot: 's1' },\n state: { type: 'string', indexSlot: 's2', validation: { enum: ['open', 'acked', 'resolved'] } },\n severity: { type: 'string', indexSlot: 's3', validation: { enum: ['minor', 'major'] } },\n acked_by: { type: 'string', indexSlot: 's4' }, // the acknowledging staff member\n opened_at: { type: 'datetime', indexSlot: 't1' },\n resolved_at: { type: 'datetime', indexSlot: 't2' },\n title: { type: 'string', validation: { max: 200 } },\n cause: { type: 'text' },\n duration_min: { type: 'float' },\n paged: { type: 'json' }, // who was paged, when \u2014 written once (null \u21D2 not yet)\n escalated_at: { type: 'datetime' }, // the secondary was paged\n },\n },\n\n // \u2B22 PUBLIC: the updates the team PUBLISHES. A note stays a draft \u2014 and a\n // draft is never served on the public lane.\n outage_updates: {\n singular: 'outage_update',\n public: true,\n fields: {\n outage: { type: 'relation', relationTo: 'outages', required: true, indexSlot: 's1' },\n kind: {\n type: 'string', required: true, indexSlot: 's2',\n validation: { enum: ['investigating', 'identified', 'monitoring', 'resolved', 'note'] },\n },\n // the author's user id: visible to signed-in staff with a role, never on\n // the public lane (a readRoles field is never served anonymously)\n author: { type: 'string', indexSlot: 's3', readRoles: ['owner', 'admin', 'viewer', 'oncall', 'monitor-admin'] },\n at: { type: 'datetime', indexSlot: 't1' },\n component: { type: 'string' }, // the status_components key it is about\n body: { type: 'text', validation: { max: 2000 } },\n },\n },\n\n // \u2B22 PUBLIC: the components on your status page and their current status.\n status_components: {\n singular: 'status_component',\n public: true,\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true }, // 'api'\n name: { type: 'string', required: true, indexSlot: 's2' }, // 'API'\n group: { type: 'string', indexSlot: 's3' },\n status: {\n type: 'string', indexSlot: 's4',\n validation: { enum: ['operational', 'degraded', 'partial_outage', 'major_outage'] },\n },\n sort: { type: 'int', indexSlot: 'n1' },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n description: { type: 'text' },\n },\n },\n\n // Who is on call, as DATA. The probe pages whoever's shift covers now.\n oncall_shifts: {\n singular: 'oncall_shift',\n fields: {\n user: { type: 'string', required: true, indexSlot: 's1' }, // the staff member's user id\n email: { type: 'string', indexSlot: 's2' }, // optional delivery override\n role: { type: 'string', indexSlot: 's3', validation: { enum: ['primary', 'secondary'] } },\n starts_at: { type: 'datetime', required: true, indexSlot: 't1' },\n ends_at: { type: 'datetime', required: true, indexSlot: 't2' },\n },\n },\n },\n },\n\n functions: {\n // THE PROBE. Every minute: due checks \u2192 probe in parallel \u2192 counters \u2192\n // once-per-crossing outages, component status and pages \u2192 escalation.\n probe: {\n entry: './functions/probe.ts',\n // Free plan: '*/15 * * * *' (its fastest function cron). `overlap: 'skip'`:\n // a tick still running never runs beside the next one.\n trigger: { kind: 'cron', schedule: '* * * * *', overlap: 'skip' },\n scopes: ['cms:read', 'cms:write', 'notifications:send'],\n secrets: ['secret:slack_webhook_url', 'secret:probe_token'],\n // Deny-by-default egress: EVERY probed host must be listed here, plus the\n // chat host ('discord.com' for Discord, 'chat.googleapis.com' for a Google\n // Chat space webhook). Private, loopback and internal hosts are unreachable\n // by design \u2014 expose a public health endpoint guarded by the probe token.\n egressAllow: ['status.example.com', 'api.example.com', 'hooks.slack.com'],\n limits: { timeoutMs: 15000 }, // per outbound fetch; a check's own timeout_ms is \u2264 this\n },\n\n // DEAD-MAN'S SWITCH. Your own cron jobs call it when they finish:\n // POST /v1/fn/heartbeat {\"check\":\"nightly-backup\"}\n // with a key holding ONLY functions:invoke, kept in their CI secret store.\n heartbeat: {\n entry: './functions/heartbeat.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // STAFF ACTIONS: ack \xB7 resolve \xB7 post_update (outages.manage) and pause \xB7\n // resume \xB7 publish_component (checks.manage), each re-checked live against\n // the caller's org role.\n 'outage-action': {\n entry: './functions/outage-action.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'orgs:read'],\n egressAllow: [],\n },\n\n // THE MORNING E-MAIL. 08:00 UTC \u2014 function crons are UTC; if you need local\n // time, enqueue it from a jobs schedule that carries a timezone instead.\n 'daily-report': {\n entry: './functions/daily-report.ts',\n trigger: { kind: 'cron', schedule: '0 8 * * *', overlap: 'skip' },\n scopes: ['cms:read', 'notifications:send'],\n egressAllow: [],\n },\n },\n\n // References only \u2014 values are stored once and never appear in this file.\n secrets: {\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord / Google Chat space webhook) URL for pages' },\n probe_token: { feature: 'functions', description: 'shared token your public health endpoints require in x-probe-token' },\n oidc_client_secret: { feature: 'auth', description: 'OIDC client secret for your identity provider' },\n },\n\n // Seeds land as DRAFTS (a seed is a plain create). Publish the two components\n // once \u2014 `outage-action` `publish_component`, or the dashboard \u2014 and they\n // appear on the status page. The http checks land PAUSED (state 'paused',\n // enabled false) until their hosts are in the probe's egressAllow; turn each\n // on with `outage-action` `resume`, whose precondition is `state: 'paused'`.\n seed: {\n cms: [\n {\n collection: 'status_components',\n items: [\n { key: 'api', name: 'API', group: 'Core', status: 'operational', sort: 1, updated_at: '2026-01-01T00:00:00Z' },\n { key: 'website', name: 'Website', group: 'Core', status: 'operational', sort: 2, updated_at: '2026-01-01T00:00:00Z' },\n ],\n },\n {\n collection: 'checks',\n items: [\n {\n key: 'api-health', name: 'API health', kind: 'http', component: 'api', state: 'paused',\n url: 'https://api.example.com/health', expect_status: 200, send_probe_token: true,\n timeout_ms: 10000, degraded_ms: 2000, interval_min: 1, failures_to_down: 2,\n enabled: false, consecutive_failures: 0, next_due_at: '2026-01-01T00:00:00Z',\n },\n {\n key: 'website-home', name: 'Website', kind: 'keyword', component: 'website', state: 'paused',\n url: 'https://status.example.com/', expect_status: 200, expect_contains: '<title>',\n timeout_ms: 10000, degraded_ms: 3000, interval_min: 5, failures_to_down: 2,\n enabled: false, consecutive_failures: 0, next_due_at: '2026-01-01T00:00:00Z',\n },\n {\n key: 'nightly-backup', name: 'Nightly backup', kind: 'heartbeat', component: 'api', state: 'up',\n heartbeat_grace_min: 1500, interval_min: 15, failures_to_down: 1,\n enabled: true, consecutive_failures: 0, next_due_at: '2026-01-01T00:00:00Z',\n },\n ],\n },\n ],\n },\n});\n",
20955
- "readme": '# Service monitor (ops)\n\nUptime and health checks for your team\'s **public endpoints**, **dead-man\'s-switch heartbeats** from your\nown scheduled jobs, outages that open and close **once per state crossing**, the on-call shift **paged from\ndata** in chat, e-mail and the in-app inbox, staff who acknowledge, resolve and post updates, and a\n**keyless public status page** that shows component status and only the updates you chose to publish.\n\nIt is four functions and six collections. There is no monitoring engine, no escalation engine and no\nconnector in it: a probe is a cron function behind a deny-by-default egress allowlist, an outage is a row\nwhose creation is guarded, a page is a read of who is on shift right now, and escalation is one more\nfilter in the same cron.\n\n```bash\nmkdir service-monitor && cd service-monitor && vxil init --template service-monitor # init scaffolds into the current directory\nvxil quickstart --env staging --no-push --invite <code> # or `vxil link <slug> --env staging`\nvxil projects workload <slug> staging # Free plan: functions deploy on staging/development\n# Free plan only: set the probe\'s schedule to \'*/15 * * * *\' in vxil.config.ts now (plan note below)\nprintf \'%s\' "$SLACK_URL" | vxil secrets set functions/slack_webhook_url\nprintf \'%s\' "$PROBE_TOKEN" | vxil secrets set functions/probe_token\nprintf \'%s\' "$OIDC_SECRET" | vxil secrets set auth/oidc_client_secret\n# now: set the oidc issuer / clientId / allowedDomains in vxil.config.ts to your identity provider\nvxil push # collections, hooks, auth, orgs, the four functions\nvxil seed # 2 components, 1 http + 1 keyword check (paused), 1 heartbeat check\n```\n\nThen, once. The org and the shifts need **user ids**, and staff only get one by signing in. Sign-in is\nOIDC only (password and magic link are off), and this blueprint ships no sign-in page: your staff app starts\nit with `vx.auth.oauth.startUrl(\'oidc\', { redirect_uri })` and finishes it at\n`GET /v1/auth/oauth/oidc/callback` (vxil.com/docs/guide/06-feature-catalog, "Starting social or SSO sign-in\nfrom a browser app"). After each person\'s first sign-in, look their id up by e-mail:\n\n```bash\nME=$(vxil api GET "/v1/users?email=you@yourco.com" | jq -r \'.users[0].id\')\nEVE=$(vxil api GET "/v1/users?email=eve@yourco.com" | jq -r \'.users[0].id\')\n```\n\nJust trying it out, with no identity provider yet? Create the rows yourself \u2014\n`vxil api POST /v1/users --data \'{"id":"usr_me","email":"you@yourco.com"}\'` \u2014 and use `usr_me` as `$ME`.\nThose users cannot sign in, but the org, the shift and the paging read all work.\n\n```bash\n# the two custom roles (`oncall`, `monitor-admin`) are declared in features.orgs.roles \u2014 `vxil push`\n# created them; `owner`, `admin`, `member` and `viewer` are built in\n# the team org (its owner holds every permission), then each staff member with a role\nORG=$(vxil api POST /v1/orgs --data \'{"slug":"platform","name":"Platform team","owner_user_id":"\'$ME\'"}\' | jq -r .org_id)\nvxil api POST /v1/orgs/$ORG/members --data \'{"user_id":"\'$EVE\'","role":"oncall"}\'\n# who is on call, as data (pick dates that include today)\nvxil api POST /v1/cms/items/oncall_shifts --data \'{"status":"published","data":{"user":"\'$EVE\'","role":"primary","starts_at":"2026-10-06T08:00:00Z","ends_at":"2026-10-13T08:00:00Z"}}\'\n```\n\n**Both probe secrets must be set before the probe can run**: a function whose declared secret has no value\nis refused at invoke (`409 secret_missing`), so the cron would fail every tick. No chat? Delete\n`\'secret:slack_webhook_url\'` from the probe\'s `secrets` (the probe then pages by e-mail and inbox only). No\ntoken-guarded endpoint yet? Set `probe_token` to any random value, or delete it the same way.\n\n> **Plan note.** Free deploys functions to `staging` and `development` projects, caps a project at 5\n> functions and 3 crons, and runs a cron every 15 minutes at the fastest. This blueprint uses 4 functions\n> and 2 crons, so it fits \u2014 change the probe\'s `schedule` to `*/15 * * * *` on Free (the config comment\n> says so), or the deploy answers `402 plan_limit`. Developer and above run it every minute.\n\n## The collections\n\n| Collection | What it holds | Who reads it |\n|---|---|---|\n| `checks` | what to watch: `key`, `kind` (`http` \xB7 `keyword` \xB7 `heartbeat`), `url`, expected status/text, timeout, `degraded_ms`, `interval_min`, `failures_to_down`, and the live state (`up` \xB7 `degraded` \xB7 `down` \xB7 `paused`) | staff |\n| `uptime_hourly` | one row per check per hour: `ok`, `total`, `latency_sum`, `latency_max` | staff, the daily report |\n| `outages` | one row per down-crossing: `open` \u2192 `acked` \u2192 `resolved`, who acked, duration, who was paged | staff |\n| `outage_updates` \u2B22 | the updates staff write; **only published ones are public** | everyone (published only) |\n| `status_components` \u2B22 | the components on your status page and their status | everyone (published only) |\n| `oncall_shifts` | who is on call, `primary` or `secondary`, from when to when | staff, the probe |\n\nThree Lane-A hooks guard the data in the same write: `outage_stage` (open \u2192 acked \u2192 resolved, resolved is\nfinal), `check_kind` and `check_url` (an http or keyword check needs an `https://` url), and `uptime_key`,\nwhich derives `point_key = check|bucket` server-side on a field declared `unique` \u2014 so one hour can never\nhave two rows for the same check.\n\n## Probes: a cron function with deny-by-default egress\n\n`probe` runs every minute. It reads the due checks \u2014 `enabled`, `next_due_at \u2264 now`, oldest first, at most\n`MAX_PER_TICK` (40) \u2014 and probes them in parallel:\n\n- **http** \u2014 `GET url`, with the check\'s `timeout_ms` (\u2264 15 s), `redirect: \'manual\'`, and the\n `x-probe-token` header when the check sets `send_probe_token`. Up when the status equals `expect_status`\n (200 by default).\n- **keyword** \u2014 the same, and the body must contain `expect_contains`.\n- **heartbeat** \u2014 no fetch at all (below).\n\nA **redirect is a failure** with reason `redirect`. The probe never follows one: a public host can redirect\nto a private one, so the platform\'s egress guard refuses to follow redirects, and the probe reports it\nrather than chasing it. Point a check at the final URL.\n\n**Every probed host must be on the probe\'s `egressAllow`.** A host that is not listed fails with\n`host not in egressAllow` \u2014 nothing leaves the function unless you named it. Add the chat host too\n(`hooks.slack.com`; `discord.com` for Discord; `chat.googleapis.com` for a Google Chat space webhook, which\ntakes `{ text }` alone \u2014 the function sends only that field to it).\n\n**What cannot be probed.** Private networks, loopback and internal hostnames are unreachable from a\nfunction by design. To watch an internal service, expose a small **public health endpoint** for it and\nguard it with the probe token:\n\n```text\nGET https://api.example.com/health x-probe-token: <probe_token>\n\u2192 200 when the service and its dependencies answer, 503 otherwise; 401 without the token\n```\n\nThe token is a per-function secret (`secret:probe_token`), so it never appears in your config or your\nrepository.\n\nThe seeded http checks land **paused** (`state: "paused"`, `enabled: false`) until their hosts are on the\nallowlist: edit the URLs, list the hosts, `vxil push`, then turn each one on with\n`{"op":"resume","check":"api-health"}` (and `website-home`) through `outage-action`. `resume` only accepts a\npaused check, sets it `up` and due now, and the next tick probes it.\n\n## Dead-man\'s-switch heartbeats\n\nSome failures make no request fail: a nightly backup that silently stopped running. A `heartbeat` check\ninverts the probe \u2014 **your job calls vxil** when it finishes, and silence is the alarm:\n\n```bash\n# the last step of the nightly backup job, wherever it runs\ncurl -s -X POST https://api.vxil.com/v1/fn/heartbeat \\\n -H "authorization: Bearer $VXIL_HEARTBEAT_KEY" -H \'content-type: application/json\' \\\n -d \'{"check":"nightly-backup"}\'\n```\n\n`VXIL_HEARTBEAT_KEY` is a **Server-class** key holding **only `functions:invoke`** (the `job-heartbeat` row in\n[the key table](#keys)), stored in that job\'s CI secret store. The\nfunction stamps `last_heartbeat_at` and makes the check due now; the probe marks the check down when the\nlast heartbeat is older than `heartbeat_grace_min` (no fetch, no egress). A check that has never beaten is\nnot judged: there is no `pending` state, so it keeps the state it has (`up` for a new check) and each probe\nrun only reschedules it, reporting it with `pending: true` in that run\'s result until the first heartbeat\narrives. The function answers `404` for an unknown check,\n`409` for a paused one or a non-heartbeat check, and `403` when it is called from a signed-in session:\nheartbeats are machine calls.\n\nA `functions:invoke` key can call any of the project\'s functions over HTTP, not only this one. Treat it\nlike any other credential, and keep `outage-action` safe on its own: it refuses a caller without a staff\nsession.\n\n## Once-per-crossing outages, paged from data\n\nThe state machine is small:\n\n- **up \u2192 down** after `failures_to_down` **consecutive** failures (2 by default). One failure is a blip.\n- **down \u2192 up** on the **first** success.\n- **degraded** when a successful answer took longer than `degraded_ms` \u2014 the component turns `degraded`\n and the chat gets one line, but no outage opens.\n\nEach tick first **claims** the check with one `If-Match` write that also moves `next_due_at` forward. A\nre-delivered or overlapping tick gets `409` there and stands down, so everything after the claim happens\nonce.\n\nOn the down-crossing the probe creates the outage under a **lock and a guard**:\n\n```json\nPOST /v1/cms/items/outages\n{ "data": { "check": "api-health", "state": "open", "severity": "major", \u2026 },\n "status": "published",\n "lock": "outage:api-health",\n "guard": { "filter": { "check": "api-health", "state": { "$in": ["open", "acked"] } }, "max": 1 } }\n```\n\nA check can therefore never have two open outages: a second create is `409 guard_failed`, which the probe\ntreats as *already open* and adopts. It then **pages the shift that covers now** \u2014 `oncall_shifts` with\n`starts_at \u2264 now < ends_at` and role `primary` \u2014 in chat once, and to each person by e-mail and in the\nin-app inbox. The page is claimed on the outage with an `if: { "paged": null }` precondition, so however\nthe tick is delivered, an outage pages at most once.\n\n**Escalation is not an engine.** In the same tick, the probe looks for outages still `open` (nobody acked)\n`ESCALATE_AFTER_MIN` (15) minutes after they opened, stamps `escalated_at` on each with a precondition, and\npages the `secondary`. Set the constant to `0` to turn it off.\n\nOn recovery the probe resolves the outage with an `if` on its state (`open` or `acked`), writes\n`duration_min`, and posts **recovered** once. If a staff member resolved it by hand while the check was\nstill failing, the probe does not reopen it until the check has recovered and failed again.\n\n## Keys\n\nMint the keys in the dashboard under **Keys \u2192 New key** (or with `vxil keys mint`, which needs a dashboard\nsession: `vxil login` first). Two of them live in the staff app, one in your jobs:\n\n| Key | Class | Scopes | Where it lives | Why |\n|---|---|---|---|---|\n| `web-auth` | Server | `auth:signin` | the staff app | signing in is how a session is obtained, so this key cannot require one |\n| `web-data` | Public / thin-client | `cms:read`, `functions:invoke` | the staff app | refused by the edge without a valid staff session |\n| `job-heartbeat` | Server | `functions:invoke` | your job\'s CI secret store, **never a browser** | heartbeats are machine calls with no session |\n\n`web-data` holds **no `cms:write`**: the staff app reads the collections directly, and every write goes\nthrough `outage-action`, which checks the person\'s permission. Because it is a thin-client key, every call\nit makes carries a signed-in session, and `heartbeat` refuses any call that carries one. **The staff app\ntherefore cannot send a heartbeat.**\n\n**Never ship a Server-class key with `functions:invoke` in a browser.** Anyone can copy a key out of a\npage. A Server-class key with no session can call `heartbeat` for any check key, as often as it likes, and\neach fake beat keeps that check `up`. A backup that stopped running would then never page anyone, which\ndefeats the heartbeat check. The heartbeat\'s only authentication is the key: the function accepts any caller\nholding `functions:invoke` *without* a session. Keep `job-heartbeat` in the job\'s secret store, give it no\nother scope, and rotate it like any other credential.\n\n`cms:read` on `web-data` lets **anyone who can sign in** read the staff collections, including check URLs,\noutages and the on-call rota. Signing in is fenced only by `allowedDomains`, so that means anyone at your\ncompany domain, not just members of the team org. Org roles gate the actions, not the reads. If the rota or\nthe internal URLs should be narrower than that, assign the identity-provider app to the on-call group only.\n\n## Staff actions\n\nYour staff app calls `outage-action` with the `web-data` key plus the signed-in staff member\'s session:\n\n```json\nPOST /v1/fn/outage-action\n{ "op": "ack", "outage_id": "\u2026" }\n{ "op": "resolve", "outage_id": "\u2026" }\n{ "op": "post_update", "outage_id": "\u2026", "kind": "identified", "body": "\u2026", "publish": true,\n "component_status": "partial_outage" }\n{ "op": "pause", "check": "api-health" }\n{ "op": "resume", "check": "api-health" }\n{ "op": "publish_component", "key": "api" }\n```\n\n`ack`, `resolve` and `post_update` need **`outages.manage`**; `pause`, `resume` and `publish_component` need\n**`checks.manage`**. The function re-checks the permission **live** on every call\n(`GET /v1/orgs/session-claims`), not from the role in the session, so a member you remove loses the buttons\nat once. Every state change is a conditional write and the `outage_stage` hook is the authority, so two\npeople pressing *Ack* at once produce one ack and one `409`.\n\n## The public status page\n\n`status_components` and `outage_updates` are `public: true`. Their **published** rows are readable with **no\nAPI key**, edge-cached, from any front end:\n\n```bash\ncurl "https://api.vxil.com/v1/cms/public/<TENANT_ID>/status_components?sort=sort"\ncurl "https://api.vxil.com/v1/cms/public/<TENANT_ID>/outage_updates?sort=-at&limit=20"\n```\n\n- **Only published rows are served.** A `post_update` with `publish: false` \u2014 and every `kind: "note"` \u2014\n is written as a **draft**: staff see it, the public never does. Notes are where internal detail belongs.\n- **Never put an internal URL, hostname or customer name in a published update.** The body is public the\n moment it is published.\n- The `author` of an update is role-gated (`readRoles`), so the public lane never serves your staff\'s ids.\n- Seeds land as drafts. Publish the two seeded components once:\n `{"op":"publish_component","key":"api"}` and `{"op":"publish_component","key":"website"}`.\n- The checks, their URLs, the outages and the shifts are **not** public. A component\'s status moves when\n the probe sees a crossing, or when staff publish an update with `component_status`.\n\n## Uptime counters\n\nOne `uptime_hourly` row per check per hour. The first sample of an hour creates the row (`point_key` is\nunique, so a racing create is a `409`, which turns into an increment on the existing row); every later\nsample is one atomic write:\n\n```json\nPATCH /v1/cms/items/uptime_hourly/<id>\n{ "$inc": { "ok": 1, "total": 1, "latency_sum": 182.4 } }\n```\n\n`$inc` is a single conditional statement \u2014 no read first, no lost update. `latency_max` is written only\nwhen a sample beats the hour\'s maximum.\n\n```\nuptime % = 100 \xD7 \u03A3 ok / \u03A3 total over the rows in the window\navg latency = \u03A3 latency_sum / \u03A3 ok (successful samples only)\n```\n\n`daily-report` (08:00 UTC) computes the first one for every check in **one server-side aggregate** \u2014\ngroup by `check`, sum `ok` and `total`, a 1-day window on `ts` \u2014 and e-mails it, worst first, with the\noutages opened that day, to whoever is on shift. Function crons run in UTC; for local time, enqueue the\nreport from a jobs schedule that carries a timezone.\n\n## Compose it\n\n- **alerts-to-slack** watches the *platform\'s own* failure events \u2014 this blueprint\'s functions failing,\n dead-lettered notifications, a quarantined function. Run both: one watches your services, the other\n watches the monitor.\n- **payments-heartbeat** is the same once-per-crossing pattern applied to silence from a payments\n integration\'s provider.\n- An **ops dashboard** in the same project can read `checks`, `outages` and `uptime_hourly` for its tiles \u2014\n the collection names here are specific to monitoring, so they do not clash.\n\n`vxil push` writes each feature\'s config as a whole, so to combine blueprints in one project, copy the\ncollections, hooks and functions of one into the other\'s `vxil.config.ts` and push that file.\n\n## Cadence and counts\n\n| | Free | Developer and above |\n|---|---|---|\n| probe cron | `*/15 * * * *` | `* * * * *` |\n| functions / crons used | 4 of 5 / 2 of 3 | 4 / 2 |\n| checks per tick | 40 (oldest due first) | 40 \u2014 raise `MAX_PER_TICK` with care: each check costs a probe and 2\u20133 writes |\n| finest interval | 15 minutes | 1 minute |\n\nA tick probes up to 40 checks; the rest wait one tick. With the default one-minute interval that is about\n40 checks per project; at `interval_min: 5` it is about 200.\n\n## What to learn from this\n\n- **Deny-by-default egress is a feature of a monitor.** The probe can reach exactly the hosts you listed \u2014\n and the public health endpoint plus a token is how an internal service joins the list safely.\n- **A crossing is a write that can only succeed once.** A lock + guard, an `If-Match` claim, and an `if`\n precondition turn an at-least-once cron into exactly one outage, one page and one "recovered".\n- **On-call is data.** Who to page is a filtered read of `oncall_shifts`, and escalation is the same cron\n asking one more question.\n- **Public by publishing.** A keyless status page needs no server: two public collections, and a draft\n that stays internal until someone chooses to publish it.\n',
21181
+ "readme": '# Service monitor (ops)\n\nUptime and health checks for your team\'s **public endpoints**, **dead-man\'s-switch heartbeats** from your\nown scheduled jobs, outages that open and close **once per state crossing**, the on-call shift **paged from\ndata** in chat, e-mail and the in-app inbox, staff who acknowledge, resolve and post updates, and a\n**keyless public status page** that shows component status and only the updates you chose to publish.\n\nIt is four functions and six collections. There is no monitoring engine, no escalation engine and no\nconnector in it: a probe is a cron function behind a deny-by-default egress allowlist, an outage is a row\nwhose creation is guarded, a page is a read of who is on shift right now, and escalation is one more\nfilter in the same cron.\n\n```bash\nmkdir service-monitor && cd service-monitor && vxil init --template service-monitor # init scaffolds into the current directory\nvxil quickstart --env staging --no-push --invite <code> # or `vxil link <slug> --env staging`\nvxil projects workload <slug> staging # Free plan: functions deploy on staging/development\n# Free plan only: set the probe\'s schedule to \'*/15 * * * *\' in vxil.config.ts now (plan note below)\nprintf \'%s\' "$SLACK_URL" | vxil secrets set functions/slack_webhook_url\nprintf \'%s\' "$PROBE_TOKEN" | vxil secrets set functions/probe_token\nprintf \'%s\' "$OIDC_SECRET" | vxil secrets set auth/oidc_client_secret\n# now: set the oidc issuer / clientId / allowedDomains in vxil.config.ts to your identity provider\nvxil push # collections, hooks, auth, orgs, the four functions\nvxil seed # 2 components, 1 http + 1 keyword check (paused), 1 heartbeat check\n```\n\nThen, once. The org and the shifts need **user ids**, and staff only get one by signing in. Sign-in is\nOIDC only (password and magic link are off), and this blueprint ships no sign-in page: your staff app starts\nit with `vx.auth.oauth.startUrl(\'oidc\', { redirect_uri })` and finishes it at\n`GET /v1/auth/oauth/oidc/callback` (vxil.com/docs/guide/06-feature-catalog, "Starting social or SSO sign-in\nfrom a browser app"). After each person\'s first sign-in, look their id up by e-mail:\n\n```bash\nME=$(vxil api GET "/v1/users?email=you@yourco.com" | jq -r \'.users[0].id\')\nEVE=$(vxil api GET "/v1/users?email=eve@yourco.com" | jq -r \'.users[0].id\')\n```\n\nJust trying it out, with no identity provider yet? Create the rows yourself \u2014\n`vxil api POST /v1/users --data \'{"id":"usr_me","email":"you@yourco.com"}\'` \u2014 and use `usr_me` as `$ME`.\nThose users cannot sign in, but the org, the shift and the paging read all work.\n\n```bash\n# the two custom roles (`oncall`, `monitor-admin`) are declared in features.orgs.roles \u2014 `vxil push`\n# created them; `owner`, `admin`, `member` and `viewer` are built in\n# the team org (its owner holds every permission), then each staff member with a role\nORG=$(vxil api POST /v1/orgs --data \'{"slug":"platform","name":"Platform team","owner_user_id":"\'$ME\'"}\' | jq -r .org_id)\nvxil api POST /v1/orgs/$ORG/members --data \'{"user_id":"\'$EVE\'","role":"oncall"}\'\n# who is on call, as data (pick dates that include today)\nvxil api POST /v1/cms/items/oncall_shifts --data \'{"status":"published","data":{"user":"\'$EVE\'","role":"primary","starts_at":"2026-10-06T08:00:00Z","ends_at":"2026-10-13T08:00:00Z"}}\'\n```\n\n**Both probe secrets must be set before the probe can run**: a function whose declared secret has no value\nis refused at invoke (`409 secret_missing`), so the cron would fail every tick. No chat? Delete\n`\'secret:slack_webhook_url\'` from the probe\'s `secrets` (the probe then pages by e-mail and inbox only). No\ntoken-guarded endpoint yet? Set `probe_token` to any random value, or delete it the same way.\n\n> **Plan note.** Free deploys functions to `staging` and `development` projects (up to 50 functions\n> and 20 crons, down to every minute), so this blueprint\'s 4 functions and 2 crons run as written,\n> probe every minute included. A Free `production` project cannot deploy functions; Developer and above can.\n\n## The collections\n\n| Collection | What it holds | Who reads it |\n|---|---|---|\n| `checks` | what to watch: `key`, `kind` (`http` \xB7 `keyword` \xB7 `heartbeat`), `url`, expected status/text, timeout, `degraded_ms`, `interval_min`, `failures_to_down`, and the live state (`up` \xB7 `degraded` \xB7 `down` \xB7 `paused`) | staff |\n| `uptime_hourly` | one row per check per hour: `ok`, `total`, `latency_sum`, `latency_max` | staff, the daily report |\n| `outages` | one row per down-crossing: `open` \u2192 `acked` \u2192 `resolved`, who acked, duration, who was paged | staff |\n| `outage_updates` \u2B22 | the updates staff write; **only published ones are public** | everyone (published only) |\n| `status_components` \u2B22 | the components on your status page and their status | everyone (published only) |\n| `oncall_shifts` | who is on call, `primary` or `secondary`, from when to when | staff, the probe |\n\nThree Lane-A hooks guard the data in the same write: `outage_stage` (open \u2192 acked \u2192 resolved, resolved is\nfinal), `check_kind` and `check_url` (an http or keyword check needs an `https://` url), and `uptime_key`,\nwhich derives `point_key = check|bucket` server-side on a field declared `unique` \u2014 so one hour can never\nhave two rows for the same check.\n\n## Probes: a cron function with deny-by-default egress\n\n`probe` runs every minute. It reads the due checks \u2014 `enabled`, `next_due_at \u2264 now`, oldest first, at most\n`MAX_PER_TICK` (40) \u2014 and probes them in parallel:\n\n- **http** \u2014 `GET url`, with the check\'s `timeout_ms` (\u2264 15 s), `redirect: \'manual\'`, and the\n `x-probe-token` header when the check sets `send_probe_token`. Up when the status equals `expect_status`\n (200 by default).\n- **keyword** \u2014 the same, and the body must contain `expect_contains`.\n- **heartbeat** \u2014 no fetch at all (below).\n\nA **redirect is a failure** with reason `redirect`. The probe never follows one: a public host can redirect\nto a private one, so the platform\'s egress guard refuses to follow redirects, and the probe reports it\nrather than chasing it. Point a check at the final URL.\n\n**Every probed host must be on the probe\'s `egressAllow`.** A host that is not listed fails with\n`host not in egressAllow` \u2014 nothing leaves the function unless you named it. Add the chat host too\n(`hooks.slack.com`; `discord.com` for Discord; `chat.googleapis.com` for a Google Chat space webhook, which\ntakes `{ text }` alone \u2014 the function sends only that field to it).\n\n**What cannot be probed.** Private networks, loopback and internal hostnames are unreachable from a\nfunction by design. To watch an internal service, expose a small **public health endpoint** for it and\nguard it with the probe token:\n\n```text\nGET https://api.example.com/health x-probe-token: <probe_token>\n\u2192 200 when the service and its dependencies answer, 503 otherwise; 401 without the token\n```\n\nThe token is a per-function secret (`secret:probe_token`), so it never appears in your config or your\nrepository.\n\nThe seeded http checks land **paused** (`state: "paused"`, `enabled: false`) until their hosts are on the\nallowlist: edit the URLs, list the hosts, `vxil push`, then turn each one on with\n`{"op":"resume","check":"api-health"}` (and `website-home`) through `outage-action`. `resume` only accepts a\npaused check, sets it `up` and due now, and the next tick probes it.\n\n## Dead-man\'s-switch heartbeats\n\nSome failures make no request fail: a nightly backup that silently stopped running. A `heartbeat` check\ninverts the probe \u2014 **your job calls vxil** when it finishes, and silence is the alarm:\n\n```bash\n# the last step of the nightly backup job, wherever it runs\ncurl -s -X POST https://api.vxil.com/v1/fn/heartbeat \\\n -H "authorization: Bearer $VXIL_HEARTBEAT_KEY" -H \'content-type: application/json\' \\\n -d \'{"check":"nightly-backup"}\'\n```\n\n`VXIL_HEARTBEAT_KEY` is a **Server-class** key holding **only `functions:invoke`** (the `job-heartbeat` row in\n[the key table](#keys)), stored in that job\'s CI secret store. The\nfunction stamps `last_heartbeat_at` and makes the check due now; the probe marks the check down when the\nlast heartbeat is older than `heartbeat_grace_min` (no fetch, no egress). A check that has never beaten is\nnot judged: there is no `pending` state, so it keeps the state it has (`up` for a new check) and each probe\nrun only reschedules it, reporting it with `pending: true` in that run\'s result until the first heartbeat\narrives. The function answers `404` for an unknown check,\n`409` for a paused one or a non-heartbeat check, and `403` when it is called from a signed-in session:\nheartbeats are machine calls.\n\nA `functions:invoke` key can call any of the project\'s functions over HTTP, not only this one. Treat it\nlike any other credential, and keep `outage-action` safe on its own: it refuses a caller without a staff\nsession.\n\n## Once-per-crossing outages, paged from data\n\nThe state machine is small:\n\n- **up \u2192 down** after `failures_to_down` **consecutive** failures (2 by default). One failure is a blip.\n- **down \u2192 up** on the **first** success.\n- **degraded** when a successful answer took longer than `degraded_ms` \u2014 the component turns `degraded`\n and the chat gets one line, but no outage opens.\n\nEach tick first **claims** the check with one `If-Match` write that also moves `next_due_at` forward. A\nre-delivered or overlapping tick gets `409` there and stands down, so everything after the claim happens\nonce.\n\nOn the down-crossing the probe creates the outage under a **lock and a guard**:\n\n```json\nPOST /v1/cms/items/outages\n{ "data": { "check": "api-health", "state": "open", "severity": "major", \u2026 },\n "status": "published",\n "lock": "outage:api-health",\n "guard": { "filter": { "check": "api-health", "state": { "$in": ["open", "acked"] } }, "max": 1 } }\n```\n\nA check can therefore never have two open outages: a second create is `409 guard_failed`, which the probe\ntreats as *already open* and adopts. It then **pages the shift that covers now** \u2014 `oncall_shifts` with\n`starts_at \u2264 now < ends_at` and role `primary` \u2014 in chat once, and to each person by e-mail and in the\nin-app inbox. The page is claimed on the outage with an `if: { "paged": null }` precondition, so however\nthe tick is delivered, an outage pages at most once.\n\n**Escalation is not an engine.** In the same tick, the probe looks for outages still `open` (nobody acked)\n`ESCALATE_AFTER_MIN` (15) minutes after they opened, stamps `escalated_at` on each with a precondition, and\npages the `secondary`. Set the constant to `0` to turn it off.\n\nOn recovery the probe resolves the outage with an `if` on its state (`open` or `acked`), writes\n`duration_min`, and posts **recovered** once. If a staff member resolved it by hand while the check was\nstill failing, the probe does not reopen it until the check has recovered and failed again.\n\n## Keys\n\nMint the keys in the dashboard under **Keys \u2192 New key** (or with `vxil keys mint`, which needs a dashboard\nsession: `vxil login` first). Two of them live in the staff app, one in your jobs:\n\n| Key | Class | Scopes | Where it lives | Why |\n|---|---|---|---|---|\n| `web-auth` | Server | `auth:signin` | the staff app | signing in is how a session is obtained, so this key cannot require one |\n| `web-data` | Public / thin-client | `cms:read`, `functions:invoke` | the staff app | refused by the edge without a valid staff session |\n| `job-heartbeat` | Server | `functions:invoke` | your job\'s CI secret store, **never a browser** | heartbeats are machine calls with no session |\n\n`web-data` holds **no `cms:write`**: the staff app reads the collections directly, and every write goes\nthrough `outage-action`, which checks the person\'s permission. Because it is a thin-client key, every call\nit makes carries a signed-in session, and `heartbeat` refuses any call that carries one. **The staff app\ntherefore cannot send a heartbeat.**\n\n**Never ship a Server-class key with `functions:invoke` in a browser.** Anyone can copy a key out of a\npage. A Server-class key with no session can call `heartbeat` for any check key, as often as it likes, and\neach fake beat keeps that check `up`. A backup that stopped running would then never page anyone, which\ndefeats the heartbeat check. The heartbeat\'s only authentication is the key: the function accepts any caller\nholding `functions:invoke` *without* a session. Keep `job-heartbeat` in the job\'s secret store, give it no\nother scope, and rotate it like any other credential.\n\n`cms:read` on `web-data` lets **anyone who can sign in** read the staff collections, including check URLs,\noutages and the on-call rota. Signing in is fenced only by `allowedDomains`, so that means anyone at your\ncompany domain, not just members of the team org. Org roles gate the actions, not the reads. If the rota or\nthe internal URLs should be narrower than that, assign the identity-provider app to the on-call group only.\n\n## Staff actions\n\nYour staff app calls `outage-action` with the `web-data` key plus the signed-in staff member\'s session:\n\n```json\nPOST /v1/fn/outage-action\n{ "op": "ack", "outage_id": "\u2026" }\n{ "op": "resolve", "outage_id": "\u2026" }\n{ "op": "post_update", "outage_id": "\u2026", "kind": "identified", "body": "\u2026", "publish": true,\n "component_status": "partial_outage" }\n{ "op": "pause", "check": "api-health" }\n{ "op": "resume", "check": "api-health" }\n{ "op": "publish_component", "key": "api" }\n```\n\n`ack`, `resolve` and `post_update` need **`outages.manage`**; `pause`, `resume` and `publish_component` need\n**`checks.manage`**. The function re-checks the permission **live** on every call\n(`GET /v1/orgs/session-claims`), not from the role in the session, so a member you remove loses the buttons\nat once. Every state change is a conditional write and the `outage_stage` hook is the authority, so two\npeople pressing *Ack* at once produce one ack and one `409`.\n\n## The public status page\n\n`status_components` and `outage_updates` are `public: true`. Their **published** rows are readable with **no\nAPI key**, edge-cached, from any front end:\n\n```bash\ncurl "https://api.vxil.com/v1/cms/public/<TENANT_ID>/status_components?sort=sort"\ncurl "https://api.vxil.com/v1/cms/public/<TENANT_ID>/outage_updates?sort=-at&limit=20"\n```\n\n- **Only published rows are served.** A `post_update` with `publish: false` \u2014 and every `kind: "note"` \u2014\n is written as a **draft**: staff see it, the public never does. Notes are where internal detail belongs.\n- **Never put an internal URL, hostname or customer name in a published update.** The body is public the\n moment it is published.\n- The `author` of an update is role-gated (`readRoles`), so the public lane never serves your staff\'s ids.\n- Seeds land as drafts. Publish the two seeded components once:\n `{"op":"publish_component","key":"api"}` and `{"op":"publish_component","key":"website"}`.\n- The checks, their URLs, the outages and the shifts are **not** public. A component\'s status moves when\n the probe sees a crossing, or when staff publish an update with `component_status`.\n\n## Uptime counters\n\nOne `uptime_hourly` row per check per hour. The first sample of an hour creates the row (`point_key` is\nunique, so a racing create is a `409`, which turns into an increment on the existing row); every later\nsample is one atomic write:\n\n```json\nPATCH /v1/cms/items/uptime_hourly/<id>\n{ "$inc": { "ok": 1, "total": 1, "latency_sum": 182.4 } }\n```\n\n`$inc` is a single conditional statement \u2014 no read first, no lost update. `latency_max` is written only\nwhen a sample beats the hour\'s maximum.\n\n```\nuptime % = 100 \xD7 \u03A3 ok / \u03A3 total over the rows in the window\navg latency = \u03A3 latency_sum / \u03A3 ok (successful samples only)\n```\n\n`daily-report` (08:00 UTC) computes the first one for every check in **one server-side aggregate** \u2014\ngroup by `check`, sum `ok` and `total`, a 1-day window on `ts` \u2014 and e-mails it, worst first, with the\noutages opened that day, to whoever is on shift. Function crons run in UTC; for local time, enqueue the\nreport from a jobs schedule that carries a timezone.\n\n## Compose it\n\n- **alerts-to-slack** watches the *platform\'s own* failure events \u2014 this blueprint\'s functions failing,\n dead-lettered notifications, a quarantined function. Run both: one watches your services, the other\n watches the monitor.\n- **payments-heartbeat** is the same once-per-crossing pattern applied to silence from a payments\n integration\'s provider.\n- An **ops dashboard** in the same project can read `checks`, `outages` and `uptime_hourly` for its tiles \u2014\n the collection names here are specific to monitoring, so they do not clash.\n\n`vxil push` writes each feature\'s config as a whole, so to combine blueprints in one project, copy the\ncollections, hooks and functions of one into the other\'s `vxil.config.ts` and push that file.\n\n## Cadence and counts\n\n| | Free | Developer and above |\n|---|---|---|\n| probe cron | `*/15 * * * *` | `* * * * *` |\n| functions / crons used | 4 of 5 / 2 of 3 | 4 / 2 |\n| checks per tick | 40 (oldest due first) | 40 \u2014 raise `MAX_PER_TICK` with care: each check costs a probe and 2\u20133 writes |\n| finest interval | 15 minutes | 1 minute |\n\nA tick probes up to 40 checks; the rest wait one tick. With the default one-minute interval that is about\n40 checks per project; at `interval_min: 5` it is about 200.\n\n## What to learn from this\n\n- **Deny-by-default egress is a feature of a monitor.** The probe can reach exactly the hosts you listed \u2014\n and the public health endpoint plus a token is how an internal service joins the list safely.\n- **A crossing is a write that can only succeed once.** A lock + guard, an `If-Match` claim, and an `if`\n precondition turn an at-least-once cron into exactly one outage, one page and one "recovered".\n- **On-call is data.** Who to page is a filtered read of `oncall_shifts`, and escalation is the same cron\n asking one more question.\n- **Public by publishing.** A keyless status page needs no server: two public collections, and a draft\n that stays internal until someone chooses to publish it.\n',
20956
21182
  "functions": {
20957
21183
  "daily-report.ts": "// daily-report.ts \u2014 THE MORNING E-MAIL (a vxil function, cron trigger).\n//\n// Trigger: cron `0 8 * * *`. Function crons run in UTC; if your team wants it\n// at 08:00 local time, enqueue it from a jobs schedule that carries a timezone.\n//\n// ONE server-side aggregate over uptime_hourly \u2014 group by check, sum ok, sum\n// total, over the last day (the `ts` window) \u2014 gives every check's uptime:\n//\n// uptime % = 100 \xD7 \u03A3 ok / \u03A3 total (per check, last 24 h)\n//\n// plus the outages opened in the same window, e-mailed to whoever is on shift\n// now. A check with no samples in the window is simply absent from the table.\n\n// cron-walk: single-read \u2014 one bounded server-side aggregate (\u2264500 groups) and one bounded outage list are the whole job; nothing to page.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_OUTAGES_LISTED = 50;\nconst MAX_LINES = 60; // checks listed in the e-mail body\n\ninterface Group { key: { check?: string }; ok?: number; total?: number }\ninterface Row<T> { item_id: string; data: T }\ninterface OutageData { check: string; state?: string; title?: string; opened_at?: string; duration_min?: number }\ninterface ShiftData { user: string; email?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as CronFunctionEnvelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notify = env.scoped_jwts?.notifications;\n if (!cms || !notify) return Response.json({ error: 'missing cms or notifications scope' }, { status: 403 });\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = new Date();\n const nowIso = now.toISOString();\n const since = new Date(now.getTime() - 86_400_000).toISOString();\n\n // 1. the aggregate: sum needs ok/total on n-slots (n1, n2); the 1-day window rides ts (t1)\n const agg = await fetch(`${base}/v1/cms/items/uptime_hourly/aggregate`, {\n method: 'POST', headers: H,\n body: JSON.stringify({\n aggregates: [\n { fn: 'sum', field: 'ok', as: 'ok' },\n { fn: 'sum', field: 'total', as: 'total' },\n ],\n groupBy: ['check'],\n window: { field: 'ts', sinceDays: 1 },\n limit: 500,\n }),\n });\n if (!agg.ok) return Response.json({ error: 'aggregate_failed', status: agg.status }, { status: 502 });\n const groups = ((await agg.json()) as { data?: { groups?: Group[] } }).data?.groups ?? [];\n\n // 2. the outages opened in the same window\n const of = encodeURIComponent(JSON.stringify({ opened_at: { $gte: since } }));\n const ol = await fetch(`${base}/v1/cms/items/outages?filter=${of}&sort=-opened_at&limit=${MAX_OUTAGES_LISTED}`, { headers: H });\n const outages = ol.ok ? ((await ol.json()) as { data?: { items?: Row<OutageData>[] } }).data?.items ?? [] : [];\n\n // 3. who gets it: everyone on shift right now\n const sf = encodeURIComponent(JSON.stringify({ starts_at: { $lte: nowIso }, ends_at: { $gt: nowIso } }));\n const sl = await fetch(`${base}/v1/cms/items/oncall_shifts?filter=${sf}&limit=20`, { headers: H });\n const shifts = sl.ok ? ((await sl.json()) as { data?: { items?: Row<ShiftData>[] } }).data?.items ?? [] : [];\n if (shifts.length === 0) return Response.json({ checks: groups.length, outages: outages.length, sent: 0, reason: 'nobody on shift' });\n\n const rows = groups\n .filter((g) => g.key.check && (g.total ?? 0) > 0)\n .map((g) => ({ check: g.key.check!, pct: (100 * (g.ok ?? 0)) / (g.total ?? 1), total: g.total ?? 0 }))\n .sort((a, b) => a.pct - b.pct); // worst first\n const lines = rows.slice(0, MAX_LINES).map((r) => `${r.check}: ${r.pct.toFixed(2)} % of ${r.total} checks`);\n if (rows.length > MAX_LINES) lines.push(`\u2026 and ${rows.length - MAX_LINES} more`);\n const outageLines = outages.map((o) => `${o.data.title ?? o.data.check} \u2014 ${o.data.state ?? 'open'}${o.data.duration_min !== undefined ? ` (${o.data.duration_min} min)` : ''}`);\n const paragraph = [\n `Uptime, last 24 h (worst first): ${lines.length ? lines.join(' \xB7 ') : 'no samples'}.`,\n `Outages opened: ${outageLines.length ? outageLines.join(' \xB7 ') : 'none'}.`,\n ].join(' ');\n\n // one report per person per day, however often this tick is delivered\n const day = nowIso.slice(0, 10);\n let sent = 0;\n for (const s of dedupe(shifts.map((r) => r.data))) {\n const res = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${notify}`, 'content-type': 'application/json', 'idempotency-key': `daily-report:${day}:${s.user}` },\n body: JSON.stringify({\n user_id: s.user,\n ...(s.email ? { to_email: s.email } : {}),\n template: 'transactional',\n channel: 'email',\n data: { subject: `Service monitor \u2014 daily report ${day}`, paragraph },\n }),\n }).catch(() => null);\n if (res?.ok) sent++;\n }\n return Response.json({ checks: rows.length, outages: outages.length, sent });\n },\n};\n\nfunction dedupe(shifts: ShiftData[]): ShiftData[] {\n const seen = new Set<string>();\n return shifts.filter((s) => (seen.has(s.user) ? false : (seen.add(s.user), true)));\n}\n",
20958
21184
  "heartbeat.ts": "// heartbeat.ts \u2014 THE DEAD-MAN'S SWITCH (a vxil function, http trigger).\n//\n// Your own scheduled jobs (a backup, an export, a nightly sync \u2014 wherever they\n// run) call this when they FINISH:\n//\n// curl -s -X POST https://api.vxil.com/v1/fn/heartbeat \\\n// -H \"authorization: Bearer $VXIL_HEARTBEAT_KEY\" -H 'content-type: application/json' \\\n// -d '{\"check\":\"nightly-backup\"}'\n//\n// with a SERVER-class key holding ONLY `functions:invoke`, kept in that job's CI\n// secret store and never shipped to a browser: the key is this endpoint's only\n// authentication, so whoever holds it can keep a dead job's check `up`. The\n// staff app's thin-client key always carries a session, which is refused here. It stamps `last_heartbeat_at` on the check and makes the check due now,\n// so the next probe tick (\u2264 1 minute) judges it \u2014 a check that was down\n// recovers on its first beat. The PROBE is what notices silence: a check whose\n// last heartbeat is older than its grace goes down there, with no fetch.\n//\n// 404 \u2014 no such check \xB7 409 \u2014 not a heartbeat check, or paused/disabled\n// 403 \u2014 called from a signed-in session: heartbeats are machine calls\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = HttpFunctionEnvelope<{ check?: string }>;\ninterface CheckData { key: string; kind?: string; state?: string; enabled?: boolean }\ninterface Row { item_id: string; data: CheckData }\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 fail(403, 'missing_scope', 'the function has no cms token');\n if (env.end_user) return fail(403, 'machine_only', 'a heartbeat comes from a job with a functions:invoke key, not from a signed-in session');\n const key = env.payload?.check;\n if (typeof key !== 'string' || !/^[A-Za-z0-9._:-]{1,120}$/.test(key)) {\n return fail(422, 'bad_check', 'send {\"check\":\"<check key>\"}');\n }\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const found = await fetch(`${base}/v1/cms/items/checks?filter=${filter}&limit=1`, { headers: H });\n if (!found.ok) return fail(502, 'lookup_failed', `checks read ${found.status}`);\n const row = ((await found.json()) as { data?: { items?: Row[] } }).data?.items?.[0];\n if (!row) return fail(404, 'unknown_check', `no check '${key}'`);\n if (row.data.kind !== 'heartbeat') return fail(409, 'not_a_heartbeat', `'${key}' is a ${row.data.kind} check`);\n if (row.data.state === 'paused' || row.data.enabled === false) return fail(409, 'paused', `'${key}' is paused`);\n\n const now = new Date().toISOString();\n const res = await fetch(`${base}/v1/cms/items/checks/${row.item_id}`, {\n method: 'PATCH',\n headers: H,\n // `if`: a check paused between the read and this write stays paused\n body: JSON.stringify({ data: { last_heartbeat_at: now, next_due_at: now }, if: { state: { $ne: 'paused' } } }),\n });\n if (res.status === 409) return fail(409, 'paused', `'${key}' is paused`);\n if (!res.ok) return fail(502, 'write_failed', `checks write ${res.status}`);\n return Response.json({ check: key, beat_at: now });\n },\n};\n\nconst fail = (status: number, code: string, message: string) => Response.json({ error: { code, message } }, { status });\n",
@@ -20977,8 +21203,8 @@ export default defineConfig({
20977
21203
  ],
20978
21204
  "hasFunctions": true,
20979
21205
  "byoKeys": [],
20980
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Job Runner\" \u2014 the BACKGROUND-WORK blueprint. Four ways work leaves the\n// request path, and what happens when it fails:\n//\n// \u2022 enqueue \u2192 one-off work, deduplicated by an idempotency key, and\n// optionally deferred (seconds from now, or an exact time)\n// \u2022 schedules \u2192 cron, with a LATE schedule reporting itself\n// \u2022 queue trigger \u2192 a deployed function that IS the worker\n// \u2022 generation \u2192 a long provider call vxil babysits for you, holding\n// credits while it runs and releasing them when it ends\n// \u2022 the spine \u2192 every failure is an audit event; one cron function turns\n// the ones that matter into `incidents` rows\n//\n// The credits half is a payments INTEGRATION with the deterministic `mock`\n// provider, so the whole reserve \u2192 settle \u2192 refund story runs with no provider\n// account at all. \"Credits\" here are usage units, not money.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n // a failing delivery is retried this many times before it dead-letters\n retry: { defaultMaxAttempts: 3 },\n retention: { successfulRunDays: 7, failedRunDays: 30 },\n // once this many runs have dead-lettered today, a further failing run\n // skips its remaining retries and dead-letters immediately \u2014 a bound on\n // the churn ONE pathological target can generate. 0 = unlimited.\n dlqDailyQuota: 200,\n concurrency: { maxConcurrent: 5 },\n schedules: { maxPerTenant: 20 },\n generation: {\n maxConcurrent: 5,\n defaultTimeoutMs: 300_000, // 5 min unless the descriptor says otherwise\n pollMaxAttempts: 30, // give up after 30 polls \u2192 terminal fail\n maxReserveCredits: 200, // a single run can never hold more than this\n maxOutstandingReserveCredits: 5_000, // \u2026nor can all in-flight runs together\n },\n },\n\n // The ledger half. `mock` is the deterministic default provider: no keys,\n // no account, the entire credits path exercisable end to end. Point it at\n // your own Stripe/Paddle/PayPal/RevenueCat account when you go live \u2014 vxil\n // is never in the flow of funds.\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n // product id \u2192 what buying it grants. Consumed by the provider webhook\n // reducer; the map is DATA, so adding products never changes the config.\n productMap: {\n render_pack_1000: { creditType: 'render_credits', amount: 1000, period: 'once' },\n },\n // tier \u2192 what being on it entitles you to, and what it tops up monthly\n tierMap: {\n pro: {\n entitlements: ['render'],\n quotas: { renders_per_day: 500 },\n rank: 10,\n grants: [{ creditType: 'render_credits', amount: 5_000, period: 'monthly' }],\n },\n },\n // a generation that ends in failure gives its held credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {},\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n // The work items. One row per requested render.\n renders: {\n singular: 'render',\n fields: {\n // THE DEDUPE ANCHOR. Queue deliveries are at-least-once, so the\n // worker may see the same item twice; a unique field turns the second\n // write into a clean 409 the function treats as \"already done\".\n request_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n title: { type: 'string', indexSlot: 's2' },\n state: { type: 'string', indexSlot: 's3' }, // queued | processing | done | failed\n run_id: { type: 'string', indexSlot: 's4' }, // the jobs run that owns it\n credits: { type: 'int', indexSlot: 'n1' }, // held for this render\n created_at: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n\n // The failure memory. One row per incident KEY, so ten dead letters of\n // the same job are one row \u2014 plus the reserved `__cursor__` row holding\n // the audit-stream watermark.\n incidents: {\n singular: 'incident',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n event: { type: 'string', indexSlot: 's2' }, // the audit event that raised it\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n subject: { type: 'string', indexSlot: 's4' }, // job name / schedule id\n seen_count: { type: 'int', indexSlot: 'n1' },\n first_seen: { type: 'datetime', indexSlot: 't1' },\n last_seen: { type: 'datetime', indexSlot: 't2' },\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n detail: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // THE WORKER. A queue-triggered function is invoked by an enqueue, not by a\n // request \u2014 it has no URL a browser can reach. Delivery is at-least-once,\n // so it dedupes on `request_key` rather than assuming exactly-once.\n 'process-batch': {\n entry: './functions/process-batch.ts',\n trigger: { kind: 'queue', source: 'renders' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // THE FAILURE DRAIN. Every feature writes its lifecycle to your tenant's\n // audit stream; this reads the stream past a watermark every 5 minutes and\n // turns the failure events that matter into `incidents` rows.\n 'incident-watch': {\n entry: './functions/incident-watch.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free plan: '*/15 * * * *' (its fastest function cron)\n // `audit:read` mints the function's confined control-plane token\n // (env.scoped_jwts['control-plane']), which reads the audit stream and\n // nothing else \u2014 no API key stored as a secret.\n scopes: ['cms:read', 'cms:write', 'audit:read'],\n egressAllow: [],\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'renders',\n items: [\n {\n request_key: 'seed-0001',\n title: 'Quarterly report render',\n state: 'queued',\n credits: 10,\n created_at: '2026-04-01T08:00:00Z',\n notes: 'Seed row so the collection is not empty on first push.',\n },\n ],\n },\n ],\n },\n});\n",
20981
- "readme": '# Job Runner (ops)\n\nFour ways work leaves the request path, and one answer for what happens when it fails. If you have\never written a `jobs` table, a worker loop, a retry counter and a "why did this run twice?" post\nmortem, this is that, declared.\n\n```bash\nmkdir my-jobs && cd my-jobs\nvxil init --template job-runner\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\n# Free plan only: set incident-watch\'s cron to \'*/15 * * * *\' in vxil.config.ts now (note below)\nvxil push # collections + both functions; writes vxil.types.ts\nvxil seed # the demo render row\n```\n\n> **Plan note.** The two functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\nThe incident drain needs no API key: `incident-watch` declares the `audit:read` scope, so its scoped\ncallback carries a confined token that reads the audit stream (`/v1/audit`, `/v1/audit/export`) and\nnothing else. Nothing else needs credentials either: the payments integration runs\non the deterministic `mock` provider, so the whole credits story works before you have a provider\naccount. "Credits" are usage units you meter, not money and not stored value.\n\n> **On the Free plan** a function cron may run at most every 15 minutes \u2014 change `incident-watch` to\n> `*/15 * * * *` in `vxil.config.ts` before `vxil push`, or the deploy answers `402 plan_limit`\n> (`cron_interval`). Paid plans run the 5-minute schedule as shipped.\n\n## The four ways work leaves the request path\n\n| | You call | It runs | Use it when |\n|---|---|---|---|\n| **Enqueue** | `POST /v1/jobs/enqueue` | now, or later | one-off work, deduplicated by a key you choose |\n| **Schedule** | `POST /v1/jobs/schedules` | on a 5-field UTC cron | recurring work \u2014 and it tells you when it ran late |\n| **Queue trigger** | `vxil functions invoke <fn> --async` | a deployed function | the worker IS your code, with no URL to expose |\n| **Generation** | `POST /v1/jobs/generation` | a long provider call vxil babysits | a model/render/export that answers in minutes, not milliseconds |\n\nAnd one answer for failure: **every feature writes its lifecycle to your audit stream**, and the\n`incident-watch` cron turns the failures that matter into rows you can query.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `jobs:read jobs:write payments:read payments:write cms:read cms:write\nfunctions:read functions:invoke features:read`. The project key quickstart minted carries these (`vxil env pull && set -a && . ./.env.local && set +a`, then `KEY=$VXIL_API_KEY`); the `vxil api` lines need no variable at all.\n\n**Reading the output comments.** Each `# 200 { "data": \u2026 }` comment shows the HTTP response. A raw\n`curl` call prints exactly that; a `vxil api` line prints only what is inside `data` (no status, no\nenvelope), ready for `jq` \u2014 so `jq .field`, not `jq .data.field`.\n\n**1. Enqueue the same thing twice.** The dedupe scope is `(job_name, idempotency_key)`:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deduplicated": true } } \u2190 the SAME run_id\n```\n\nNote where the key goes: **in the body**, as `idempotency_key`. (The `Idempotency-Key` *header* is\nwhat the notifications and payments surfaces read \u2014 jobs reads the field.) The window is 24 hours,\nand it is backstopped in the database, so two racing enqueues cannot both win.\n\n**2. Run it later.** Two mutually exclusive fields \u2014 pick one:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","delay_seconds":900}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deliver_after": "\u2026T12:26:12.686Z" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","deliver_after":"2026-12-24T09:00:00Z"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "delayed", "deliver_after": "2026-12-24T09:00:00.000Z" } }\n```\n\n`state` tells you which machinery is holding it: a short delay rides the queue itself and fires on\nthe second; anything beyond twelve hours is parked as `delayed` and released by a minute-tick\nsweep. The ceiling is 30 days. A `deliver_after` in the past is a 422, not a surprise.\n\n**3. A schedule that reports itself late.**\n\n```bash\nvxil api POST /v1/jobs/schedules --data \'{"job_name":"nightly.rollup","target_url":"https://hooks.example.com/rollup","cron":"0 2 * * *"}\'\n# 201 { "data": { "schedule_id": "sch_\u2026", "next_run_at": "2026-09-12T02:00:00.000Z", "state": "active" } }\n```\n\nFive UTC fields, with `*`, numbers, lists, ranges and steps \u2014 no month or weekday names, no `L`/`W`.\nWhen a tick finally fires more than **twice its own interval** late, the platform writes\n`jobs.schedule.missed` (once per crossing, not once per tick) and `jobs.schedule.recovered` when it\ncatches up. Step 7 turns both into rows.\n\n**4. The worker is your function.** `process-batch` is queue-triggered \u2014 it has no URL a browser can\nreach. Hand it a batch:\n\n```bash\nvxil functions invoke process-batch --async --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api GET "/v1/cms/items/renders?filter=%7B%22state%22%3A%22queued%22%7D"\n# 200 \u2026 two new rows, request_key "b-1:a" and "b-1:b"\n```\n\n(`--async` enqueues it; drop the flag to call the same function synchronously and\nread its answer. `--async` needs a linked project \u2014 `vxil quickstart` or `vxil link <slug>`.)\n\nNow send the **exact same batch again**, synchronously so you can read the verdict:\n\n```bash\nvxil functions invoke process-batch --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# { "batch_id": "b-1", "created": 0, "duplicates": 2, "rejected": 0 }\n```\n\nNothing was duplicated, and the function contains no dedupe logic. `request_key` is declared\n`unique`, so the second create is a 409 \u2014 and the function reads a 409 as *already done*. That is\nthe whole strategy: **delivery is at-least-once, so correctness lives in a declared field, not in a\nhope that it runs once.**\n\n**5. Credits: reserve \u2192 settle.** Grant some usage units first (the header is required here \u2014 this\nis the money-shaped surface):\n\n```bash\ncurl -s -X POST "https://api.vxil.com/v1/payments/credits/grant" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -H \'idempotency-key: seed-1\' \\\n -d \'{"user_id":"u_demo","credit_type":"render_credits","amount":100,"source":"seed"}\'\n# 200 { "data": { "balance_after": 100, "ledger_entry_id": null } }\n\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "credit_type": "render_credits", "balance": 100, "held": 0, "available": 100, \u2026 } }\n```\n\nNow start a generation that holds ten of them while it runs:\n\n```bash\nvxil api POST /v1/jobs/generation --data \'{\n "job_name": "render.deck",\n "provider": { "url": "https://api.your-render-provider.example/v1/renders",\n "method": "POST",\n "body": { "format": "pdf" } },\n "completion": { "mode": "poll",\n "status_path": "status",\n "poll": { "url": "https://api.your-render-provider.example/v1/renders/latest",\n "method": "GET", "interval_ms": 5000 } },\n "reserve_credits": { "amount": 10, "user_id": "u_demo",\n "credit_type": "render_credits", "reason": "deck render" },\n "timeout": { "after_ms": 120000 },\n "payload": { "render_id": "b-1:a" }\n}\'\n# 202 { "data": { "run_id": "run_\u2026", "generation_status": "pending", "state": "queued" } }\n```\n\nImmediately, the balance moves \u2014 but only the **held** half:\n\n```bash\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "balance": 100, "held": 10, "available": 90, \u2026 } }\n\nvxil api GET "/v1/payments/usage?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "user_id": "u_demo", "entries": [\n# { "kind": "consume", "state": "provisional", "delta": -10, "job_id": "run_\u2026", "source": "deck render", \u2026 },\n# { "kind": "grant", "state": "committed", "delta": 100, \u2026 } ] } }\n```\n\n`state: "provisional"` is the reservation. Nothing has been spent yet \u2014 `balance` is untouched and\n`available` dropped, so the same user cannot start ten more renders on credits they do not have.\n\nWhen the run reaches a terminal state, vxil settles it for you, and the ledger says which way it\nwent:\n\n- **completed** \u2192 the provisional row flips to `committed` and a `settle` row lands with\n `source: "job:succeeded"`. The credits were spent.\n- **failed / timed out** \u2192 a `reversal` row lands with a **positive** delta and\n `source: "job:failed"`, the original flips to `reversed`, and `held` returns to zero. The\n customer was not charged for work that did not happen. That refund is the\n `ledger.autoRefundOnJobFailure` flag in `vxil.config.ts`; turn it off and a failure still releases\n the hold but keeps the charge.\n\nTwo ceilings you do not have to remember to set: a single run\'s request is **clamped** to\n`generation.maxReserveCredits` (never rejected, so a bad caller cannot break the flow), and the sum\nof all outstanding holds is refused past `generation.maxOutstandingReserveCredits` with a 429. Both\nhave safe defaults.\n\nPoll mode is exactly what it says: vxil calls your provider once, then re-reads\n`completion.poll.url` every `interval_ms`, reading `status_path` out of the body. Anything it does\nnot recognise counts as *still processing* \u2014 a generation is never silently completed by a typo.\nAfter `pollMaxAttempts` it fails terminally, and the timeout does the same on the wall clock.\n\n**6. Dead letters and replay.** There is no separate dead-letter inbox \u2014 a dead letter is a run in\nthe `dead` state:\n\n```bash\nvxil api GET "/v1/jobs/runs?state=dead&limit=20"\n# 200 { "data": { "runs": [ { "run_id": "run_\u2026", "job_name": "report.email", "state": "dead",\n# "attempt_number": 1, "max_attempts": 1,\n# "last_error_class": "NonRetryableHttp",\n# "last_error_msg": "target returned 404", \u2026 } ] } }\n\nvxil api POST /v1/jobs/runs/run_\u2026/replay\n# 202 { "data": { "run_id": "run_NEW", "replayed_from": "run_\u2026", "state": "queued" } }\n```\n\nReplay takes **no body** and clones the original into a *new* run \u2014 the dead row is evidence and\nstays untouched. Only terminal runs replay; anything still in flight is a `409 not_replayable`.\n\n**7. Failures become rows.** `incident-watch` runs every five minutes (every fifteen on the Free plan). Force it once:\n\n```bash\nvxil functions invoke incident-watch\n# first run: { "initialized": true, "cursor": "\u2026", "raised": 0 } \u2190 history never floods you\n# after a dead letter, the next run:\n# { "scanned": 12, "raised": 1, "resolved": 0, "cursor": "\u2026" }\n\nvxil api GET /v1/cms/items/incidents\n# 200 \u2026 { "key": "jobs:dead-letter:report.email", "event": "job.dead_lettered", "level": "error",\n# "subject": "report.email", "seen_count": 1, "first_seen": "\u2026", "last_seen": "\u2026",\n# "detail": "run run_\u2026 died after attempt 1" }\n```\n\nTen dead letters of the same job produce **one** row with `seen_count: 10`, because the incident\nkey collapses them. `jobs.schedule.missed` and `jobs.schedule.recovered` share a key, so a schedule\nthat catches up closes its own incident. The `RULES` table in `functions/incident-watch.ts` is the\nallow-list \u2014 plain data. Widen it from the catalog of everything the platform can emit.\n\nThis blueprint does not enable `webhooks`, so the project key has no webhooks scopes and the two\ncalls below would get `403 insufficient_scope` through `vxil api`. Mint a key that has them (after\n`vxil login`) and call with it:\n\n```bash\nHOOKS_KEY=$(vxil keys mint --name ops-hooks --scopes webhooks:read,webhooks:write --json | jq -r .api_key)\ncurl -s https://api.vxil.com/v1/webhooks/events/catalog -H "authorization: Bearer $HOOKS_KEY"\n# { "count": \u2026, "events": [ { "name": "job.dead_lettered", "feature": "jobs",\n# "level": "failure", "payload_keys": [ \u2026 ] } \u2026 ],\n# "prefixes": [ { "prefix": "payments.", "count": \u2026 }, \u2026 ] } \u2190 one entry per event prefix\n```\n\nIf you would rather the same events went to **your own** endpoint than into a collection, subscribe\nto the spine directly \u2014 same events, different consumer:\n\n```bash\ncurl -s -X POST https://api.vxil.com/v1/webhooks/subscriptions \\\n -H "authorization: Bearer $HOOKS_KEY" -H \'content-type: application/json\' \\\n -d \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["job.","jobs."]}\'\n```\n\n## What to learn from this\n\n- **Exactly-once is not a delivery guarantee you can buy; it is a field you declare.** `unique` on\n `request_key`, `idempotency_key` on the enqueue, `foreign_id` elsewhere \u2014 each converts\n at-least-once delivery into an at-most-once *effect*.\n- **A reservation is not a charge.** Holding credits while long work runs, and releasing them if it\n fails, is the difference between metering and billing people for your outages. The ledger shows\n both halves, so you can answer "why was I charged?" from a query.\n- **Failure needs a vocabulary, not a log.** `job.dead_lettered`, `jobs.schedule.missed`,\n `job.generation.failed` are named events with stable payloads \u2014 that is why a 40-line function can\n turn them into an incident board, and why the catalog route can tell an agent what exists.\n- **Late is a different failure from broken.** A schedule that fires twice its interval late says so\n once, and says so again when it recovers. Alerting on every tick teaches people to mute you.\n- **The clamp beats the rejection.** Capping a requested hold, rather than refusing it, keeps a\n careless caller from breaking the flow while still bounding the blast radius.\n\n**Pairs with:** the **alerts-to-slack** blueprint (the same drain, routed to a chat channel instead of a\ncollection) and the **payments-heartbeat** blueprint (noticing the failure that is *silence*).\n',
21206
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Job Runner\" \u2014 the BACKGROUND-WORK blueprint. Four ways work leaves the\n// request path, and what happens when it fails:\n//\n// \u2022 enqueue \u2192 one-off work, deduplicated by an idempotency key, and\n// optionally deferred (seconds from now, or an exact time)\n// \u2022 schedules \u2192 cron, with a LATE schedule reporting itself\n// \u2022 queue trigger \u2192 a deployed function that IS the worker\n// \u2022 generation \u2192 a long provider call vxil babysits for you, holding\n// credits while it runs and releasing them when it ends\n// \u2022 the spine \u2192 every failure is an audit event; one cron function turns\n// the ones that matter into `incidents` rows\n//\n// The credits half is a payments INTEGRATION with the deterministic `mock`\n// provider, so the whole reserve \u2192 settle \u2192 refund story runs with no provider\n// account at all. \"Credits\" here are usage units, not money.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n // a failing delivery is retried this many times before it dead-letters\n retry: { defaultMaxAttempts: 3 },\n retention: { successfulRunDays: 7, failedRunDays: 30 },\n // once this many runs have dead-lettered today, a further failing run\n // skips its remaining retries and dead-letters immediately \u2014 a bound on\n // the churn ONE pathological target can generate. 0 = unlimited.\n dlqDailyQuota: 200,\n concurrency: { maxConcurrent: 5 },\n schedules: { maxPerTenant: 20 },\n generation: {\n maxConcurrent: 5,\n defaultTimeoutMs: 300_000, // 5 min unless the descriptor says otherwise\n pollMaxAttempts: 30, // give up after 30 polls \u2192 terminal fail\n maxReserveCredits: 200, // a single run can never hold more than this\n maxOutstandingReserveCredits: 5_000, // \u2026nor can all in-flight runs together\n },\n },\n\n // The ledger half. `mock` is the deterministic default provider: no keys,\n // no account, the entire credits path exercisable end to end. Point it at\n // your own Stripe/Paddle/PayPal/RevenueCat account when you go live \u2014 vxil\n // is never in the flow of funds.\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n // product id \u2192 what buying it grants. Consumed by the provider webhook\n // reducer; the map is DATA, so adding products never changes the config.\n productMap: {\n render_pack_1000: { creditType: 'render_credits', amount: 1000, period: 'once' },\n },\n // tier \u2192 what being on it entitles you to, and what it tops up monthly\n tierMap: {\n pro: {\n entitlements: ['render'],\n quotas: { renders_per_day: 500 },\n rank: 10,\n grants: [{ creditType: 'render_credits', amount: 5_000, period: 'monthly' }],\n },\n },\n // a generation that ends in failure gives its held credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {},\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n // The work items. One row per requested render.\n renders: {\n singular: 'render',\n fields: {\n // THE DEDUPE ANCHOR. Queue deliveries are at-least-once, so the\n // worker may see the same item twice; a unique field turns the second\n // write into a clean 409 the function treats as \"already done\".\n request_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n title: { type: 'string', indexSlot: 's2' },\n state: { type: 'string', indexSlot: 's3' }, // queued | processing | done | failed\n run_id: { type: 'string', indexSlot: 's4' }, // the jobs run that owns it\n credits: { type: 'int', indexSlot: 'n1' }, // held for this render\n created_at: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n\n // The failure memory. One row per incident KEY, so ten dead letters of\n // the same job are one row \u2014 plus the reserved `__cursor__` row holding\n // the audit-stream watermark.\n incidents: {\n singular: 'incident',\n fields: {\n key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n event: { type: 'string', indexSlot: 's2' }, // the audit event that raised it\n level: { type: 'string', indexSlot: 's3' }, // info | warn | error\n subject: { type: 'string', indexSlot: 's4' }, // job name / schedule id\n seen_count: { type: 'int', indexSlot: 'n1' },\n first_seen: { type: 'datetime', indexSlot: 't1' },\n last_seen: { type: 'datetime', indexSlot: 't2' },\n cursor: { type: 'string' }, // `__cursor__` row only: last processed audit id\n detail: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // THE WORKER. A queue-triggered function is invoked by an enqueue, not by a\n // request \u2014 it has no URL a browser can reach. Delivery is at-least-once,\n // so it dedupes on `request_key` rather than assuming exactly-once.\n 'process-batch': {\n entry: './functions/process-batch.ts',\n trigger: { kind: 'queue', source: 'renders' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // THE FAILURE DRAIN. Every feature writes its lifecycle to your tenant's\n // audit stream; this reads the stream past a watermark every 5 minutes and\n // turns the failure events that matter into `incidents` rows.\n 'incident-watch': {\n entry: './functions/incident-watch.ts',\n trigger: { kind: 'cron', schedule: '*/5 * * * *' }, // Free staging/development: deploys as is; '*/15 * * * *' keeps it inside the 500,000 CPU-ms day\n // `audit:read` mints the function's confined control-plane token\n // (env.scoped_jwts['control-plane']), which reads the audit stream and\n // nothing else \u2014 no API key stored as a secret.\n scopes: ['cms:read', 'cms:write', 'audit:read'],\n egressAllow: [],\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'renders',\n items: [\n {\n request_key: 'seed-0001',\n title: 'Quarterly report render',\n state: 'queued',\n credits: 10,\n created_at: '2026-04-01T08:00:00Z',\n notes: 'Seed row so the collection is not empty on first push.',\n },\n ],\n },\n ],\n },\n});\n",
21207
+ "readme": '# Job Runner (ops)\n\nFour ways work leaves the request path, and one answer for what happens when it fails. If you have\never written a `jobs` table, a worker loop, a retry counter and a "why did this run twice?" post\nmortem, this is that, declared.\n\n```bash\nmkdir my-jobs && cd my-jobs\nvxil init --template job-runner\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail (or `vxil link <slug> --env staging`)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\n# Free plan, optional: set incident-watch\'s cron to \'*/15 * * * *\' to keep it inside the daily CPU budget (note below)\nvxil push # collections + both functions; writes vxil.types.ts\nvxil seed # the demo render row\n```\n\n> **Plan note.** The two functions deploy on the Free plan when the project\'s workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config\'s first push today). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n\nThe incident drain needs no API key: `incident-watch` declares the `audit:read` scope, so its scoped\ncallback carries a confined token that reads the audit stream (`/v1/audit`, `/v1/audit/export`) and\nnothing else. Nothing else needs credentials either: the payments integration runs\non the deterministic `mock` provider, so the whole credits story works before you have a provider\naccount. "Credits" are usage units you meter, not money and not stored value.\n\n> **On the Free plan** (staging or development projects) the 5-minute cron deploys as shipped, but every call\n> counts its full 2,000 ms CPU limit against the project\'s 500,000 CPU-ms a day, so `*/5` alone spends the day\'s\n> budget by late evening UTC. Set `incident-watch` to `*/15 * * * *` to keep the functions callable all day.\n> Paid plans run the 5-minute schedule as shipped.\n\n## The four ways work leaves the request path\n\n| | You call | It runs | Use it when |\n|---|---|---|---|\n| **Enqueue** | `POST /v1/jobs/enqueue` | now, or later | one-off work, deduplicated by a key you choose |\n| **Schedule** | `POST /v1/jobs/schedules` | on a 5-field UTC cron | recurring work \u2014 and it tells you when it ran late |\n| **Queue trigger** | `vxil functions invoke <fn> --async` | a deployed function | the worker IS your code, with no URL to expose |\n| **Generation** | `POST /v1/jobs/generation` | a long provider call vxil babysits | a model/render/export that answers in minutes, not milliseconds |\n\nAnd one answer for failure: **every feature writes its lifecycle to your audit stream**, and the\n`incident-watch` cron turns the failures that matter into rows you can query.\n\n## The 10-minute walkthrough\n\n`$KEY` is a server key with `jobs:read jobs:write payments:read payments:write cms:read cms:write\nfunctions:read functions:invoke features:read`. The project key quickstart minted carries these (`vxil env pull && set -a && . ./.env.local && set +a`, then `KEY=$VXIL_API_KEY`); the `vxil api` lines need no variable at all.\n\n**Reading the output comments.** Each `# 200 { "data": \u2026 }` comment shows the HTTP response. A raw\n`curl` call prints exactly that; a `vxil api` line prints only what is inside `data` (no status, no\nenvelope), ready for `jq` \u2014 so `jq .field`, not `jq .data.field`.\n\n**1. Enqueue the same thing twice.** The dedupe scope is `(job_name, idempotency_key)`:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"report.email","target_url":"https://hooks.example.com/report","payload":{"report_id":"r_42"},"idempotency_key":"r_42"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deduplicated": true } } \u2190 the SAME run_id\n```\n\nNote where the key goes: **in the body**, as `idempotency_key`. (The `Idempotency-Key` *header* is\nwhat the notifications and payments surfaces read \u2014 jobs reads the field.) The window is 24 hours,\nand it is backstopped in the database, so two racing enqueues cannot both win.\n\n**2. Run it later.** Two mutually exclusive fields \u2014 pick one:\n\n```bash\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","delay_seconds":900}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued", "deliver_after": "\u2026T12:26:12.686Z" } }\n\nvxil api POST /v1/jobs/enqueue --data \'{"job_name":"nudge","target_url":"https://hooks.example.com/nudge","deliver_after":"2026-12-24T09:00:00Z"}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "delayed", "deliver_after": "2026-12-24T09:00:00.000Z" } }\n```\n\n`state` tells you which machinery is holding it: a short delay rides the queue itself and fires on\nthe second; anything beyond twelve hours is parked as `delayed` and released by a minute-tick\nsweep. The ceiling is 30 days. A `deliver_after` in the past is a 422, not a surprise.\n\n**3. A schedule that reports itself late.**\n\n```bash\nvxil api POST /v1/jobs/schedules --data \'{"job_name":"nightly.rollup","target_url":"https://hooks.example.com/rollup","cron":"0 2 * * *"}\'\n# 201 { "data": { "schedule_id": "sch_\u2026", "next_run_at": "2026-09-12T02:00:00.000Z", "state": "active" } }\n```\n\nFive UTC fields, with `*`, numbers, lists, ranges and steps \u2014 no month or weekday names, no `L`/`W`.\nWhen a tick finally fires more than **twice its own interval** late, the platform writes\n`jobs.schedule.missed` (once per crossing, not once per tick) and `jobs.schedule.recovered` when it\ncatches up. Step 7 turns both into rows.\n\n**4. The worker is your function.** `process-batch` is queue-triggered \u2014 it has no URL a browser can\nreach. Hand it a batch:\n\n```bash\nvxil functions invoke process-batch --async --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# 202 { "data": { "run_id": "run_\u2026", "state": "queued" } }\n\nvxil api GET "/v1/cms/items/renders?filter=%7B%22state%22%3A%22queued%22%7D"\n# 200 \u2026 two new rows, request_key "b-1:a" and "b-1:b"\n```\n\n(`--async` enqueues it; drop the flag to call the same function synchronously and\nread its answer. `--async` needs a linked project \u2014 `vxil quickstart` or `vxil link <slug>`.)\n\nNow send the **exact same batch again**, synchronously so you can read the verdict:\n\n```bash\nvxil functions invoke process-batch --data \'{"batch_id":"b-1","items":[{"key":"b-1:a","title":"Q3 deck","credits":10},{"key":"b-1:b","title":"Q3 memo","credits":5}]}\'\n# { "batch_id": "b-1", "created": 0, "duplicates": 2, "rejected": 0 }\n```\n\nNothing was duplicated, and the function contains no dedupe logic. `request_key` is declared\n`unique`, so the second create is a 409 \u2014 and the function reads a 409 as *already done*. That is\nthe whole strategy: **delivery is at-least-once, so correctness lives in a declared field, not in a\nhope that it runs once.**\n\n**5. Credits: reserve \u2192 settle.** Grant some usage units first (the header is required here \u2014 this\nis the money-shaped surface):\n\n```bash\ncurl -s -X POST "https://api.vxil.com/v1/payments/credits/grant" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -H \'idempotency-key: seed-1\' \\\n -d \'{"user_id":"u_demo","credit_type":"render_credits","amount":100,"source":"seed"}\'\n# 200 { "data": { "balance_after": 100, "ledger_entry_id": null } }\n\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "credit_type": "render_credits", "balance": 100, "held": 0, "available": 100, \u2026 } }\n```\n\nNow start a generation that holds ten of them while it runs:\n\n```bash\nvxil api POST /v1/jobs/generation --data \'{\n "job_name": "render.deck",\n "provider": { "url": "https://api.your-render-provider.example/v1/renders",\n "method": "POST",\n "body": { "format": "pdf" } },\n "completion": { "mode": "poll",\n "status_path": "status",\n "poll": { "url": "https://api.your-render-provider.example/v1/renders/latest",\n "method": "GET", "interval_ms": 5000 } },\n "reserve_credits": { "amount": 10, "user_id": "u_demo",\n "credit_type": "render_credits", "reason": "deck render" },\n "timeout": { "after_ms": 120000 },\n "payload": { "render_id": "b-1:a" }\n}\'\n# 202 { "data": { "run_id": "run_\u2026", "generation_status": "pending", "state": "queued" } }\n```\n\nImmediately, the balance moves \u2014 but only the **held** half:\n\n```bash\nvxil api GET "/v1/payments/credits/balance?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "balance": 100, "held": 10, "available": 90, \u2026 } }\n\nvxil api GET "/v1/payments/usage?user_id=u_demo&credit_type=render_credits"\n# 200 { "data": { "user_id": "u_demo", "entries": [\n# { "kind": "consume", "state": "provisional", "delta": -10, "job_id": "run_\u2026", "source": "deck render", \u2026 },\n# { "kind": "grant", "state": "committed", "delta": 100, \u2026 } ] } }\n```\n\n`state: "provisional"` is the reservation. Nothing has been spent yet \u2014 `balance` is untouched and\n`available` dropped, so the same user cannot start ten more renders on credits they do not have.\n\nWhen the run reaches a terminal state, vxil settles it for you, and the ledger says which way it\nwent:\n\n- **completed** \u2192 the provisional row flips to `committed` and a `settle` row lands with\n `source: "job:succeeded"`. The credits were spent.\n- **failed / timed out** \u2192 a `reversal` row lands with a **positive** delta and\n `source: "job:failed"`, the original flips to `reversed`, and `held` returns to zero. The\n customer was not charged for work that did not happen. That refund is the\n `ledger.autoRefundOnJobFailure` flag in `vxil.config.ts`; turn it off and a failure still releases\n the hold but keeps the charge.\n\nTwo ceilings you do not have to remember to set: a single run\'s request is **clamped** to\n`generation.maxReserveCredits` (never rejected, so a bad caller cannot break the flow), and the sum\nof all outstanding holds is refused past `generation.maxOutstandingReserveCredits` with a 429. Both\nhave safe defaults.\n\nPoll mode is exactly what it says: vxil calls your provider once, then re-reads\n`completion.poll.url` every `interval_ms`, reading `status_path` out of the body. Anything it does\nnot recognise counts as *still processing* \u2014 a generation is never silently completed by a typo.\nAfter `pollMaxAttempts` it fails terminally, and the timeout does the same on the wall clock.\n\n**6. Dead letters and replay.** There is no separate dead-letter inbox \u2014 a dead letter is a run in\nthe `dead` state:\n\n```bash\nvxil api GET "/v1/jobs/runs?state=dead&limit=20"\n# 200 { "data": { "runs": [ { "run_id": "run_\u2026", "job_name": "report.email", "state": "dead",\n# "attempt_number": 1, "max_attempts": 1,\n# "last_error_class": "NonRetryableHttp",\n# "last_error_msg": "target returned 404", \u2026 } ] } }\n\nvxil api POST /v1/jobs/runs/run_\u2026/replay\n# 202 { "data": { "run_id": "run_NEW", "replayed_from": "run_\u2026", "state": "queued" } }\n```\n\nReplay takes **no body** and clones the original into a *new* run \u2014 the dead row is evidence and\nstays untouched. Only terminal runs replay; anything still in flight is a `409 not_replayable`.\n\n**7. Failures become rows.** `incident-watch` runs every five minutes (or every fifteen, if you relaxed it on the Free plan). Force it once:\n\n```bash\nvxil functions invoke incident-watch\n# first run: { "initialized": true, "cursor": "\u2026", "raised": 0 } \u2190 history never floods you\n# after a dead letter, the next run:\n# { "scanned": 12, "raised": 1, "resolved": 0, "cursor": "\u2026" }\n\nvxil api GET /v1/cms/items/incidents\n# 200 \u2026 { "key": "jobs:dead-letter:report.email", "event": "job.dead_lettered", "level": "error",\n# "subject": "report.email", "seen_count": 1, "first_seen": "\u2026", "last_seen": "\u2026",\n# "detail": "run run_\u2026 died after attempt 1" }\n```\n\nTen dead letters of the same job produce **one** row with `seen_count: 10`, because the incident\nkey collapses them. `jobs.schedule.missed` and `jobs.schedule.recovered` share a key, so a schedule\nthat catches up closes its own incident. The `RULES` table in `functions/incident-watch.ts` is the\nallow-list \u2014 plain data. Widen it from the catalog of everything the platform can emit.\n\nThis blueprint does not enable `webhooks`, so the project key has no webhooks scopes and the two\ncalls below would get `403 insufficient_scope` through `vxil api`. Mint a key that has them (after\n`vxil login`) and call with it:\n\n```bash\nHOOKS_KEY=$(vxil keys mint --name ops-hooks --scopes webhooks:read,webhooks:write --json | jq -r .api_key)\ncurl -s https://api.vxil.com/v1/webhooks/events/catalog -H "authorization: Bearer $HOOKS_KEY"\n# { "count": \u2026, "events": [ { "name": "job.dead_lettered", "feature": "jobs",\n# "level": "failure", "payload_keys": [ \u2026 ] } \u2026 ],\n# "prefixes": [ { "prefix": "payments.", "count": \u2026 }, \u2026 ] } \u2190 one entry per event prefix\n```\n\nIf you would rather the same events went to **your own** endpoint than into a collection, subscribe\nto the spine directly \u2014 same events, different consumer:\n\n```bash\ncurl -s -X POST https://api.vxil.com/v1/webhooks/subscriptions \\\n -H "authorization: Bearer $HOOKS_KEY" -H \'content-type: application/json\' \\\n -d \'{"target_url":"https://ops.example.com/vxil","event_prefixes":["job.","jobs."]}\'\n```\n\n## What to learn from this\n\n- **Exactly-once is not a delivery guarantee you can buy; it is a field you declare.** `unique` on\n `request_key`, `idempotency_key` on the enqueue, `foreign_id` elsewhere \u2014 each converts\n at-least-once delivery into an at-most-once *effect*.\n- **A reservation is not a charge.** Holding credits while long work runs, and releasing them if it\n fails, is the difference between metering and billing people for your outages. The ledger shows\n both halves, so you can answer "why was I charged?" from a query.\n- **Failure needs a vocabulary, not a log.** `job.dead_lettered`, `jobs.schedule.missed`,\n `job.generation.failed` are named events with stable payloads \u2014 that is why a 40-line function can\n turn them into an incident board, and why the catalog route can tell an agent what exists.\n- **Late is a different failure from broken.** A schedule that fires twice its interval late says so\n once, and says so again when it recovers. Alerting on every tick teaches people to mute you.\n- **The clamp beats the rejection.** Capping a requested hold, rather than refusing it, keeps a\n careless caller from breaking the flow while still bounding the blast radius.\n\n**Pairs with:** the **alerts-to-slack** blueprint (the same drain, routed to a chat channel instead of a\ncollection) and the **payments-heartbeat** blueprint (noticing the failure that is *silence*).\n',
20982
21208
  "functions": {
20983
21209
  "incident-watch.ts": "// incident-watch.ts \u2014 FAILURE EVENTS \u2192 `incidents` ROWS (a vxil function).\n//\n// Trigger: cron `*/5 * * * *`. Every feature writes its lifecycle to your\n// tenant's AUDIT STREAM \u2014 that stream is the event spine, and this function is\n// one consumer of it. Each run:\n// 1. reads the watermark (the reserved `__cursor__` row in `incidents`; the\n// first run stores the NEWEST id and stops, so history never floods you),\n// 2. drains `GET /v1/audit/export?after_id=\u2026` (ascending NDJSON) past it,\n// 3. matches each row against RULES \u2014 an allow-list, not a firehose,\n// 4. upserts ONE row per incident key (ten dead letters of the same job are\n// one row with `seen_count: 10`, not ten rows),\n// 5. advances the watermark with If-Match, so an overlapping tick stands down\n// instead of double-counting.\n//\n// NO KEY: the function declares `audit:read`, so its scoped callback carries a\n// confined control-plane token (env.scoped_jwts['control-plane']) that reads\n// the audit stream and nothing else.\n//\n// To fan the same events out to YOUR OWN https endpoint instead, subscribe:\n// POST /v1/webhooks/subscriptions { \"target_url\": \"\u2026\", \"event_prefixes\": [\"job.\"] }\n// Both ride the same spine; this one keeps the state inside your backend.\n\n// cron-walk: persisted-cursor \u2014 the audit watermark lives in the `__cursor__` row and advances with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst MAX_PAGES_PER_RUN = 5; // \xD7 500 rows \u2014 bounds one tick\nconst DETAIL_MAX = 240;\n\ntype Level = 'info' | 'warn' | 'error';\ntype Payload = Record<string, unknown>;\ninterface Rule {\n level: Level;\n key: (p: Payload) => string;\n subject: (p: Payload) => string;\n detail: (p: Payload) => string;\n /** a healing event: clears the row rather than incrementing it */\n resolves?: boolean;\n}\n\nconst str = (v: unknown, fallback = 'unknown') => (typeof v === 'string' && v ? v : fallback);\nconst num = (v: unknown) => (typeof v === 'number' ? v : null);\n\n/** THE ALLOW-LIST. Every name below is an audit event the jobs feature writes\n * today; anything not listed is ignored. Own this table \u2014 it is data, not a\n * routing engine. `GET /v1/webhooks/events/catalog` lists every event the\n * platform can emit if you want to widen it. */\nconst RULES: Record<string, Rule> = {\n // a run exhausted its retries\n 'job.dead_lettered': {\n level: 'error',\n key: (p) => `jobs:dead-letter:${str(p.job_name)}`,\n subject: (p) => str(p.job_name),\n detail: (p) => `run ${str(p.run_id)} died after attempt ${num(p.attempt) ?? '?'}`,\n },\n // the tenant burned its daily dead-letter budget \u2014 a louder, rarer signal\n 'job.dead_letter_quota_exceeded': {\n level: 'error',\n key: () => 'jobs:dead-letter-quota',\n subject: (p) => str(p.job_name),\n detail: (p) => `daily dead-letter quota spent; run ${str(p.run_id)} skipped its retries`,\n },\n // a schedule fired more than 2\xD7 its own interval late\n 'jobs.schedule.missed': {\n level: 'warn',\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n subject: (p) => str(p.job_name),\n detail: (p) =>\n `expected ${str(p.expected_at)}, observed ${str(p.observed_at)} `\n + `(${num(p.late_seconds) ?? '?'}s late on a ${num(p.interval_seconds) ?? '?'}s interval)`,\n },\n // \u2026and its healing twin, sharing the SAME key, so recovery closes the row\n 'jobs.schedule.recovered': {\n level: 'info',\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n subject: (p) => str(p.job_name),\n detail: (p) => `back on time at ${str(p.observed_at)}`,\n resolves: true,\n },\n // a generation run ended in a terminal failure (its held credits are released)\n 'job.generation.failed': {\n level: 'error',\n key: (p) => `jobs:generation:${str(p.error_class, 'unclassified')}`,\n subject: (p) => str(p.error_class, 'unclassified'),\n detail: (p) => `run ${str(p.run_id)} failed (${str(p.error_class, 'no error class')})`,\n },\n};\n\ninterface AuditRow { id?: string | number; event?: string; created_at?: string; payload?: Payload }\ninterface Item { item_id: string; version: number; data: Record<string, unknown> }\n// the schedule tick, or a hand invoke (`POST /v1/fn/incident-watch`) carrying `{ dry_run }`\ntype Env = CronFunctionEnvelope | HttpFunctionEnvelope<{ dry_run?: boolean }>;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const readKey = env.scoped_jwts?.['control-plane'];\n if (!cms || !readKey) {\n return Response.json({ error: 'missing cms or audit:read scope' }, { status: 503 });\n }\n const store = new Store(base, cms);\n\n // 1. the watermark\n const cursorRow = await store.byKey('__cursor__');\n if (!cursorRow) {\n const newest = await newestAuditId(base, readKey);\n await store.create({ key: '__cursor__', cursor: newest, last_seen: new Date().toISOString() });\n return Response.json({ initialized: true, cursor: newest, raised: 0 });\n }\n let cursor = String(cursorRow.data.cursor ?? '0');\n\n // 2. drain\n const rows: AuditRow[] = [];\n for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {\n const { rows: batch, next } = await drain(base, readKey, cursor);\n rows.push(...batch);\n if (batch.length > 0) cursor = String(batch[batch.length - 1]!.id ?? cursor);\n if (!next || batch.length === 0) break;\n cursor = next;\n }\n\n // 3 + 4. match and upsert\n let raised = 0;\n let resolved = 0;\n const now = new Date().toISOString();\n for (const row of rows) {\n const rule = RULES[String(row.event ?? '')];\n if (!rule) continue;\n const p = row.payload ?? {};\n const key = rule.key(p);\n const existing = await store.byKey(key);\n\n if (rule.resolves) {\n if (existing) {\n await store.patch(existing, {\n level: 'info', event: String(row.event), detail: rule.detail(p).slice(0, DETAIL_MAX),\n last_seen: now,\n });\n resolved++;\n }\n continue;\n }\n if (existing) {\n await store.patch(existing, {\n event: String(row.event),\n level: rule.level,\n subject: rule.subject(p),\n seen_count: Number(existing.data.seen_count ?? 0) + 1,\n detail: rule.detail(p).slice(0, DETAIL_MAX),\n last_seen: now,\n });\n } else {\n await store.create({\n key,\n event: String(row.event),\n level: rule.level,\n subject: rule.subject(p),\n seen_count: 1,\n first_seen: now,\n last_seen: now,\n detail: rule.detail(p).slice(0, DETAIL_MAX),\n });\n }\n raised++;\n }\n\n // 5. advance \u2014 If-Match, so exactly one overlapping run wins\n if (!env.payload?.dry_run) {\n await store.patch(cursorRow, { cursor, last_seen: now });\n }\n return Response.json({ scanned: rows.length, raised, resolved, cursor });\n },\n};\n\n// \u2500\u2500 the audit stream (ascending NDJSON; `x-vxil-next-after-id` continues it) \u2500\u2500\nasync function drain(\n base: string, key: string, afterId: string,\n): Promise<{ rows: AuditRow[]; next: string | null }> {\n const res = await fetch(\n `${base}/v1/audit/export?after_id=${encodeURIComponent(afterId)}&limit=500`,\n { headers: { authorization: `Bearer ${key}` } },\n );\n if (!res.ok) throw new Error(`audit export ${res.status}`);\n const rows = (await res.text()).split('\\n').filter(Boolean).map((l) => JSON.parse(l) as AuditRow);\n return { rows, next: res.headers.get('x-vxil-next-after-id') };\n}\nasync function newestAuditId(base: string, key: string): Promise<string> {\n const res = await fetch(`${base}/v1/audit?limit=1`, { headers: { authorization: `Bearer ${key}` } });\n if (!res.ok) throw new Error(`audit list ${res.status}`);\n const body = (await res.json()) as { data?: { events?: AuditRow[] } };\n return String(body.data?.events?.[0]?.id ?? '0');\n}\n\n// \u2500\u2500 the cms store (the REST envelope is { data: { items: [{ item_id, version, data }] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() {\n return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' };\n }\n async byKey(key: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${this.base}/v1/cms/items/incidents?filter=${filter}&limit=1`, {\n headers: this.h(),\n });\n if (!res.ok) return null;\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 \u2014 a concurrent run already claimed this unique key */\n async create(data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/incidents`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n /** false on 409 \u2014 a concurrent run already moved this row past `version` */\n async patch(item: Item, data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/incidents/${item.item_id}`, {\n method: 'PATCH',\n headers: { ...this.h(), 'if-match': String(item.version) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n",
20984
21210
  "process-batch.ts": "// process-batch.ts \u2014 THE WORKER (a vxil function).\n//\n// Trigger: queue. This function has no URL a browser can reach \u2014 it runs because\n// something was ENQUEUED. The envelope it receives is:\n// { trigger: 'queue', tenant_id, request_id, idempotency_key, vxil_base,\n// scoped_jwts, secrets, payload }\n// where `payload` is exactly the object you put inside the enqueue body's\n// `payload.payload`, and `idempotency_key` is stable per run.\n//\n// DELIVERY IS AT-LEAST-ONCE. A retry, an overlapping tick, or a replay can hand\n// you the same batch twice, so correctness cannot rest on \"it runs once\". Here\n// the `request_key` field on `renders` is declared `unique`, which turns the\n// second create into a clean 409 \u2014 and a 409 is not an error, it is the answer\n// \"already done\". That is the whole dedupe strategy: one declared field, and a\n// status code you agree to read as success.\n\nimport type { QueueFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = QueueFunctionEnvelope<{ batch_id?: string; items?: Array<{ key?: string; title?: string; credits?: number }> }>;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n const batchId = String(env.payload?.batch_id ?? env.idempotency_key ?? 'batch');\n const items = Array.isArray(env.payload?.items) ? env.payload!.items! : [];\n if (items.length === 0) return Response.json({ batch_id: batchId, created: 0, duplicates: 0 });\n\n let created = 0;\n let duplicates = 0;\n let rejected = 0;\n\n for (const [i, item] of items.entries()) {\n // Derive a stable key per item so the SAME batch always produces the SAME\n // keys \u2014 that is what makes the redelivery a duplicate rather than a copy.\n const requestKey = String(item.key ?? `${batchId}:${i}`);\n const res = await fetch(`${base}/v1/cms/items/renders`, {\n method: 'POST',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: {\n request_key: requestKey,\n title: String(item.title ?? requestKey),\n state: 'queued',\n run_id: env.idempotency_key ?? null,\n credits: typeof item.credits === 'number' ? item.credits : 0,\n created_at: new Date().toISOString(),\n },\n }),\n });\n if (res.ok) created++;\n else if (res.status === 409) duplicates++; // already processed \u2014 the point of `unique`\n else rejected++;\n }\n\n return Response.json({ batch_id: batchId, created, duplicates, rejected });\n },\n};\n"
@@ -21031,7 +21257,7 @@ export default defineConfig({
21031
21257
  "hasFunctions": true,
21032
21258
  "byoKeys": [],
21033
21259
  "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// \"Fan-out / Fan-in\" \u2014 split one request into parts, run every part as its\n// own background job, and aggregate ONCE when the last part has ended.\n//\n// start-report (your function, http)\n// \u2192 creates-or-finds the `reports` row (unique request_key)\n// \u2192 writes one `report_parts` row per part (unique part_key)\n// \u2192 POST /v1/jobs/enqueue-batch with batch_id = 'report:<item_id>' and\n// batch_total = the number of parts \u2014 each part a job that runs\n// compute-part, idempotent per part\n// compute-part (queue trigger, retried up to 3 times)\n// \u2192 computes ITS part and writes the result onto its `report_parts` row\n// vxil\n// \u2192 when EVERY part's run has ended (succeeded, dead-lettered, expired or\n// cancelled) it emits ONE `job.batch.completed` event:\n// { batch_id, total, succeeded, dead_lettered, cancelled }\n// finish-report (webhook trigger on `job.batch.`)\n// \u2192 reads the parts, aggregates, and finishes the `reports` row\n// (status 'done', or 'partial' when some parts failed)\n//\n// vxil runs no \"next step\" for you: the batch is a counter and one event. The\n// aggregation is your own function, so the platform stays a queue, not a\n// workflow engine \u2014 and every step here is safe to run twice.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: { enabled: true },\n cms: {\n // a row is live the moment it is written\n draftPublish: false,\n },\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n reports: {\n singular: 'report',\n fields: {\n // THE RESUME ANCHOR: a repeated start with the same key is a 409 that\n // start-report reads back and resumes \u2014 never a second report\n request_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n // splitting \u2192 running \u2192 done | partial\n status: { type: 'string', indexSlot: 's2' },\n // the input, kept so a resumed start fans out the SAME parts\n numbers: { type: 'json' },\n part_size: { type: 'int' },\n parts_total: { type: 'int', indexSlot: 'n1' },\n // written by finish-report\n parts_done: { type: 'int' },\n parts_failed: { type: 'int' },\n total: { type: 'float' },\n batch_id: { type: 'text' },\n created_at: { type: 'datetime', indexSlot: 't1' },\n completed_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n report_parts: {\n singular: 'report_part',\n fields: {\n // '<report item_id>:<index>' \u2014 a re-created part is a 409, so a\n // resumed start never doubles a part\n part_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n // the parent `reports` item_id: finish-report reads the parts by it\n report: { type: 'string', required: true, indexSlot: 's2' },\n // pending \u2192 done (a part whose job dead-lettered stays pending)\n status: { type: 'string', indexSlot: 's3' },\n index: { type: 'int', indexSlot: 'n1' },\n numbers: { type: 'json' },\n // written by compute-part\n result: { type: 'float' },\n attempt_key: { type: 'text' },\n },\n },\n },\n },\n\n functions: {\n // Starts (or resumes) ONE report. Invoke it with a server key:\n // vxil functions invoke start-report --data '{ \"request_key\": \"q3\", \"numbers\": [1,2,3,4,5], \"part_size\": 2 }'\n 'start-report': {\n entry: './functions/start-report.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'jobs:write'],\n egressAllow: [],\n signature: {\n input: { request_key: 'string', numbers: 'number[]', part_size: 'number | undefined' },\n output:\n '{ report_id: string; batch_id: string; parts: number; status: string; resumed?: boolean }',\n },\n },\n\n // ONE part, delivered by the job start-report enqueued for it. A non-2xx\n // answer is retried (up to 3 attempts); a part that still fails ends its\n // run dead-lettered \u2014 counted in job.batch.completed, never a stuck batch.\n 'compute-part': {\n entry: './functions/compute-part.ts',\n trigger: { kind: 'queue', source: 'report-parts', retry: { maxAttempts: 3 } },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n\n // job.batch.completed \u2192 aggregate the parts and finish the report. The\n // event is emitted once per batch; its delivery is at-least-once, so the\n // finishing write is conditional (`if: { status: 'running' }`).\n 'finish-report': {\n entry: './functions/finish-report.ts',\n trigger: { kind: 'webhook', source: 'job.batch.', retry: { maxAttempts: 3 } },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [],\n },\n },\n});\n",
21034
- "readme": '# Fan-out / Fan-in\n\nSplit one request into many parts, run every part as its own background job\nwith retries, and aggregate **exactly once** when the last part has ended. The\npattern behind "summarise 300 documents, then write the report", "render 40\nthumbnails, then publish the album", or "price 500 items, then total the quote".\n\n```\nstart-report (http) compute-part (queue) \xD7N finish-report (webhook)\n reports row \u2500\u2500\u25BA N report_parts rows \u2500\u2500\u25BA one job per part \u2500\u2500\u25BA each writes its result\n one enqueue-batch with \u2502\n batch_id = report:<id> \u25BC\n every part ended \u2192 job.batch.completed (once)\n \u2502\n \u25BC\n aggregate the rows, finish the report\n```\n\n**What it teaches that no other blueprint does:** the **batch** \u2014 a\n`batch_id` on enqueue that ties many runs together, and the one\n`job.batch.completed` event vxil emits when every run in it is terminal. There\nis no workflow engine and no "next step" run by the platform: the batch is a\ncounter and an event, and the aggregation is your own function.\n\n## Try it\n\n```bash\nmkdir my-fan-in && cd my-fan-in # `vxil init` writes into the current directory\nvxil init --template fan-in\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # the two collections + the three functions; writes vxil.types.ts\nvxil functions invoke start-report --data \'{ "request_key": "q3-totals", "numbers": [1,2,3,4,5,6,7,8,9,10], "part_size": 3 }\'\n# \u2192 { "report_id": "itm_\u2026", "batch_id": "report:itm_\u2026", "parts": 4, "status": "running" }\n```\n\n`start-report` answers `202 Accepted`; `vxil functions invoke` prints only the body, not the status.\n\n> **Plan note.** The three functions fit the Free plan (50 functions on a staging or development project, 5 on a dev slot; no cron here) once the project\'s\n> workload is `staging` or `development`, which is the `vxil projects workload` step above. On a Free\n> `production` project, `vxil push` stops before it writes anything with `402 tier_required`, naming the\n> ways out: change the workload, upgrade to Developer, or `vxil push --skip-functions`. Free also caps\n> CPU at 50 ms per function call (waiting on `fetch` does not count): keep the work in `compute-part`\n> small per part, or the call is stopped with `503 cpu_limit_exceeded`, and a part that always\n> overruns ends dead-lettered (the report then finishes `partial`).\n\nA few seconds later the `reports` row reads `status: "done"`, `total: 55`,\n`parts_done: 4`. Watch the batch itself:\n\n```bash\nvxil api GET /v1/jobs/batches/report:itm_\u2026\n# { "state": "completed", "total": 4, "succeeded": 4, "dead_lettered": 0, "cancelled": 0,\n# "runs_by_state": { "succeeded": 4 }, "event_emitted_at": "\u2026" }\nvxil api GET \'/v1/jobs/runs?batch_id=report:itm_\u2026\'\n```\n\n## How the pieces fit\n\n| Step | Where | What makes it safe to run twice |\n| --- | --- | --- |\n| Create or find the report | `start-report` | `request_key` is `unique`: a repeat is a `409` that is read back and **resumed** |\n| Write one row per part | `start-report` | `part_key` (`<report id>:<index>`) is `unique`: a repeat is a `409` |\n| Enqueue one job per part | `start-report` \u2192 `POST /v1/jobs/enqueue-batch` | each job\'s `idempotency_key` is `<batch_id>:<index>`: a repeat hands back the same run |\n| Compute one part | `compute-part` | a part already `done` answers at once; the result written is the same every time |\n| Aggregate once | `finish-report` on `job.batch.completed` | the finishing write is conditional (`if: { status: \'running\' }`): a redelivered event\'s write is a `409` |\n\n### The batch\n\n`start-report` enqueues every part with the same `batch_id` (`report:<item id>`)\nand declares `batch_total` \u2014 the number of parts. vxil counts the batch\'s runs\nin the same transaction that ends each one. When the last one ends \u2014\n**succeeded, dead-lettered (retries exhausted), expired or cancelled** \u2014 it\nwrites one `job.batch.completed`:\n\n```json\n{ "batch_id": "report:itm_\u2026", "total": 4, "succeeded": 4, "dead_lettered": 0, "cancelled": 0 }\n```\n\n- **Once per batch.** A completed batch takes no new runs (`409 batch_closed`);\n replaying a part later creates a new run outside the batch, so the event is\n never sent twice. Its *delivery* to your function is at-least-once, which is\n why `finish-report` is re-entrant.\n- **Big fan-outs.** One enqueue-batch call takes 100 jobs; `start-report` sends\n up to five calls (500 parts). `batch_total` keeps the batch open until the\n last call has enqueued, so a fast first part can never complete the batch\n early. A different `batch_total` on a later call is `409 batch_total_mismatch`.\n- **A part that never starts** (the project at its concurrency cap, a long\n backlog) is ended after `ttl_seconds` (`PART_TTL_SECONDS`, one hour) as\n `Expired` and counted in `dead_lettered` \u2014 the report finishes `partial`\n instead of waiting forever.\n\n### Failure\n\n`compute-part` declares `retry: { maxAttempts: 3 }`: a non-2xx answer is tried\nagain with backoff, and a part that still fails ends its run dead-lettered. The\nbatch still completes; `finish-report` marks the report **`partial`** with\n`parts_failed`. To retry a failed part, replay its run (`vxil api POST\n/v1/jobs/runs/<run_id>/replay`) \u2014 the part row updates, but the report keeps\nthe result it was finished with; start a new report (a new `request_key`) for a\nfresh total.\n\n### Resuming\n\nCall `start-report` again with the same `request_key` after a lost answer or a\ncrash: it reads the **stored** input (a different `numbers` on the repeat is\nignored), writes only the missing part rows, re-sends the enqueue (a no-op for\nparts that already have a run), and answers `resumed: true`. A finished report\nanswers its final `status`.\n\n## Make it yours\n\n- **The work** is the one marked line in `functions/compute-part.ts`. Anything a\n function can do fits: call an API, run a model, transform a file.\n- **The aggregate** is in `functions/finish-report.ts`; it reads results from the\n part rows, never from events, so nothing is lost when a delivery repeats.\n- **Throughput:** the project\'s jobs concurrency setting and a\n flow rule (vxil.com/docs/guide/06-feature-catalog, the jobs section) bound how many parts run at\n once \u2014 the fan-out queues, it never overloads a downstream API.\n- **Watching progress:** `GET /v1/jobs/batches/{batch_id}` gives open / succeeded\n / dead-lettered counts while the batch runs; the dashboard\'s Jobs page lists\n its runs (`?batch_id=`).\n\n## Limits\n\n| | |\n| --- | --- |\n| Parts per report (this blueprint) | 500 (`MAX_PARTS`) |\n| Jobs per enqueue-batch call | 100 |\n| Runs per batch | 100,000 |\n| `batch_id` | 1\u2013200 characters, `a-z A-Z 0-9 _ . : -` |\n| Part start deadline | 1 hour (`PART_TTL_SECONDS`, 60 s \u2013 31 days) |\n',
21260
+ "readme": '# Fan-out / Fan-in\n\nSplit one request into many parts, run every part as its own background job\nwith retries, and aggregate **exactly once** when the last part has ended. The\npattern behind "summarise 300 documents, then write the report", "render 40\nthumbnails, then publish the album", or "price 500 items, then total the quote".\n\n```\nstart-report (http) compute-part (queue) \xD7N finish-report (webhook)\n reports row \u2500\u2500\u25BA N report_parts rows \u2500\u2500\u25BA one job per part \u2500\u2500\u25BA each writes its result\n one enqueue-batch with \u2502\n batch_id = report:<id> \u25BC\n every part ended \u2192 job.batch.completed (once)\n \u2502\n \u25BC\n aggregate the rows, finish the report\n```\n\n**What it teaches that no other blueprint does:** the **batch** \u2014 a\n`batch_id` on enqueue that ties many runs together, and the one\n`job.batch.completed` event vxil emits when every run in it is terminal. There\nis no workflow engine and no "next step" run by the platform: the batch is a\ncounter and an event, and the aggregation is your own function.\n\n## Try it\n\n```bash\nmkdir my-fan-in && cd my-fan-in # `vxil init` writes into the current directory\nvxil init --template fan-in\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # the two collections + the three functions; writes vxil.types.ts\nvxil functions invoke start-report --data \'{ "request_key": "q3-totals", "numbers": [1,2,3,4,5,6,7,8,9,10], "part_size": 3 }\'\n# \u2192 { "report_id": "itm_\u2026", "batch_id": "report:itm_\u2026", "parts": 4, "status": "running" }\n```\n\n`start-report` answers `202 Accepted`; `vxil functions invoke` prints only the body, not the status.\n\n> **Plan note.** The three functions fit the Free plan (50 functions on a staging or development project, 5 on a dev slot; no cron here) once the project\'s\n> workload is `staging` or `development`, which is the `vxil projects workload` step above. On a Free\n> `production` project, `vxil push` stops before it writes anything with `402 tier_required`, naming the\n> ways out: change the workload, upgrade to Developer, or `vxil push --skip-functions`. On a Free\n> staging/development project a call gets up to 2,000 ms of CPU (waiting on `fetch` does not count),\n> within 500,000 CPU-ms a day: keep the work in `compute-part` small per part, or the call is stopped with `503 cpu_limit_exceeded`, and a part that always\n> overruns ends dead-lettered (the report then finishes `partial`).\n\nA few seconds later the `reports` row reads `status: "done"`, `total: 55`,\n`parts_done: 4`. Watch the batch itself:\n\n```bash\nvxil api GET /v1/jobs/batches/report:itm_\u2026\n# { "state": "completed", "total": 4, "succeeded": 4, "dead_lettered": 0, "cancelled": 0,\n# "runs_by_state": { "succeeded": 4 }, "event_emitted_at": "\u2026" }\nvxil api GET \'/v1/jobs/runs?batch_id=report:itm_\u2026\'\n```\n\n## How the pieces fit\n\n| Step | Where | What makes it safe to run twice |\n| --- | --- | --- |\n| Create or find the report | `start-report` | `request_key` is `unique`: a repeat is a `409` that is read back and **resumed** |\n| Write one row per part | `start-report` | `part_key` (`<report id>:<index>`) is `unique`: a repeat is a `409` |\n| Enqueue one job per part | `start-report` \u2192 `POST /v1/jobs/enqueue-batch` | each job\'s `idempotency_key` is `<batch_id>:<index>`: a repeat hands back the same run |\n| Compute one part | `compute-part` | a part already `done` answers at once; the result written is the same every time |\n| Aggregate once | `finish-report` on `job.batch.completed` | the finishing write is conditional (`if: { status: \'running\' }`): a redelivered event\'s write is a `409` |\n\n### The batch\n\n`start-report` enqueues every part with the same `batch_id` (`report:<item id>`)\nand declares `batch_total` \u2014 the number of parts. vxil counts the batch\'s runs\nin the same transaction that ends each one. When the last one ends \u2014\n**succeeded, dead-lettered (retries exhausted), expired or cancelled** \u2014 it\nwrites one `job.batch.completed`:\n\n```json\n{ "batch_id": "report:itm_\u2026", "total": 4, "succeeded": 4, "dead_lettered": 0, "cancelled": 0 }\n```\n\n- **Once per batch.** A completed batch takes no new runs (`409 batch_closed`);\n replaying a part later creates a new run outside the batch, so the event is\n never sent twice. Its *delivery* to your function is at-least-once, which is\n why `finish-report` is re-entrant.\n- **Big fan-outs.** One enqueue-batch call takes 100 jobs; `start-report` sends\n up to five calls (500 parts). `batch_total` keeps the batch open until the\n last call has enqueued, so a fast first part can never complete the batch\n early. A different `batch_total` on a later call is `409 batch_total_mismatch`.\n- **A part that never starts** (the project at its concurrency cap, a long\n backlog) is ended after `ttl_seconds` (`PART_TTL_SECONDS`, one hour) as\n `Expired` and counted in `dead_lettered` \u2014 the report finishes `partial`\n instead of waiting forever.\n\n### Failure\n\n`compute-part` declares `retry: { maxAttempts: 3 }`: a non-2xx answer is tried\nagain with backoff, and a part that still fails ends its run dead-lettered. The\nbatch still completes; `finish-report` marks the report **`partial`** with\n`parts_failed`. To retry a failed part, replay its run (`vxil api POST\n/v1/jobs/runs/<run_id>/replay`) \u2014 the part row updates, but the report keeps\nthe result it was finished with; start a new report (a new `request_key`) for a\nfresh total.\n\n### Resuming\n\nCall `start-report` again with the same `request_key` after a lost answer or a\ncrash: it reads the **stored** input (a different `numbers` on the repeat is\nignored), writes only the missing part rows, re-sends the enqueue (a no-op for\nparts that already have a run), and answers `resumed: true`. A finished report\nanswers its final `status`.\n\n## Make it yours\n\n- **The work** is the one marked line in `functions/compute-part.ts`. Anything a\n function can do fits: call an API, run a model, transform a file.\n- **The aggregate** is in `functions/finish-report.ts`; it reads results from the\n part rows, never from events, so nothing is lost when a delivery repeats.\n- **Throughput:** the project\'s jobs concurrency setting and a\n flow rule (vxil.com/docs/guide/06-feature-catalog, the jobs section) bound how many parts run at\n once \u2014 the fan-out queues, it never overloads a downstream API.\n- **Watching progress:** `GET /v1/jobs/batches/{batch_id}` gives open / succeeded\n / dead-lettered counts while the batch runs; the dashboard\'s Jobs page lists\n its runs (`?batch_id=`).\n\n## Limits\n\n| | |\n| --- | --- |\n| Parts per report (this blueprint) | 500 (`MAX_PARTS`) |\n| Jobs per enqueue-batch call | 100 |\n| Runs per batch | 100,000 |\n| `batch_id` | 1\u2013200 characters, `a-z A-Z 0-9 _ . : -` |\n| Part start deadline | 1 hour (`PART_TTL_SECONDS`, 60 s \u2013 31 days) |\n',
21035
21261
  "functions": {
21036
21262
  "compute-part.ts": "// compute-part.ts \u2014 ONE part of the fan-out (a vxil function, queue trigger).\n//\n// Delivered by the job start-report enqueued for this part; the envelope's\n// `payload` is { report_id, part_key }. The \"work\" here is a sum \u2014 replace it\n// with yours (an API call, a model call, a file to process). Keep the result\n// on the part's own row: finish-report aggregates from the rows, not from\n// events, so nothing is lost if a delivery is repeated.\n//\n// AT-LEAST-ONCE: the same part can be delivered again (a retry, a redelivery).\n// A part already `done` answers at once; otherwise the result is computed and\n// written \u2014 the same value every time, so a second write changes nothing.\n// A non-2xx answer is retried (the trigger declares `retry: { maxAttempts: 3 }`);\n// a part that keeps failing ends its run dead-lettered, and the batch still\n// completes \u2014 finish-report then marks the report `partial`.\n\nimport type { QueueFunctionEnvelope } from '@vxil/sdk';\n\ntype Payload = { report_id?: string; part_key?: string };\ntype PartRow = { item_id: string; data: { status?: string; numbers?: unknown } };\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as QueueFunctionEnvelope<Payload>;\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 partKey = typeof env.payload?.part_key === 'string' ? env.payload.part_key : '';\n // a malformed delivery can never succeed: acknowledge it (2xx) so it is not retried\n if (!partKey) return Response.json({ skipped: 'no part_key' });\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n const filter = encodeURIComponent(JSON.stringify({ part_key: partKey }));\n const found = await fetch(`${base}/v1/cms/items/report_parts?filter=${filter}&limit=1`, { headers: H });\n if (!found.ok) return Response.json({ error: `part read: ${found.status}` }, { status: 502 }); // another attempt (header note)\n const part = ((await found.json()) as { data?: { items?: PartRow[] } }).data?.items?.[0];\n if (!part) return Response.json({ skipped: 'part not found' });\n if (part.data.status === 'done') return Response.json({ part_key: partKey, already: true });\n\n const nums = Array.isArray(part.data.numbers) ? part.data.numbers.filter((n): n is number => typeof n === 'number') : [];\n const result = nums.reduce((a, b) => a + b, 0); // \u2190 your work goes here\n\n const patched = await fetch(`${base}/v1/cms/items/report_parts/${encodeURIComponent(part.item_id)}`, {\n method: 'PATCH', headers: H,\n body: JSON.stringify({ data: { status: 'done', result, attempt_key: env.idempotency_key } }),\n });\n if (!patched.ok) return Response.json({ error: `part write: ${patched.status}` }, { status: 502 }); // another attempt (header note)\n return Response.json({ part_key: partKey, result });\n },\n};\n",
21037
21263
  "finish-report.ts": "// finish-report.ts \u2014 FAN-IN: job.batch.completed \u2192 aggregate the parts and\n// finish the report (a vxil function, webhook trigger on `job.batch.`).\n//\n// vxil emits job.batch.completed ONCE per batch, when every run in it has\n// ended: { batch_id, total, succeeded, dead_lettered, cancelled }. Its\n// DELIVERY is at-least-once (a retry after a non-2xx answer, a redelivery), so\n// this function is re-entrant: it reads the parts, computes the same answer\n// every time, and finishes the report with a CONDITIONAL write\n// (`if: { status: 'running' }`) \u2014 the second delivery's write is a 409 and\n// changes nothing.\n//\n// Batches that are not reports (another feature of your app using batch_id)\n// are skipped by their prefix.\n\nimport type { JobBatchCompletedEventPayload, WebhookFunctionEnvelope } from '@vxil/sdk';\n\ntype ReportRow = { data: { status?: string; parts_total?: number } };\ntype PartRow = { data: { status?: string; result?: number } };\n\n/** The parts read per page while aggregating. */\nconst PAGE = 100;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as WebhookFunctionEnvelope<JobBatchCompletedEventPayload>;\n const d = env.payload?.data;\n if (!d || 'truncated' in d || typeof d.batch_id !== 'string') return Response.json({ skipped: true });\n if (!d.batch_id.startsWith('report:')) return Response.json({ skipped: 'not a report batch' });\n const reportId = d.batch_id.slice('report:'.length);\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const base = env.vxil_base;\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const reportUrl = `${base}/v1/cms/items/reports/${encodeURIComponent(reportId)}`;\n\n const rep = await fetch(reportUrl, { headers: H });\n if (rep.status === 404) return Response.json({ skipped: 'report gone' });\n if (!rep.ok) return Response.json({ error: `report read: ${rep.status}` }, { status: 502 }); // another attempt (header note)\n const report = ((await rep.json()) as { data: ReportRow }).data;\n if (report.data.status !== 'running') return Response.json({ skipped: `report is ${report.data.status ?? 'unknown'}` });\n\n // aggregate from the rows (the source of truth), page by page\n let total = 0;\n let done = 0;\n let cursor: string | undefined;\n const filter = encodeURIComponent(JSON.stringify({ report: reportId }));\n for (;;) {\n const page = await fetch(\n `${base}/v1/cms/items/report_parts?filter=${filter}&limit=${PAGE}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,\n { headers: H },\n );\n if (!page.ok) return Response.json({ error: `parts read: ${page.status}` }, { status: 502 }); // another attempt (header note)\n const body = (await page.json()) as { data?: { items?: PartRow[]; next_cursor?: string | null } };\n for (const p of body.data?.items ?? []) {\n if (p.data.status === 'done' && typeof p.data.result === 'number') {\n total += p.data.result;\n done += 1;\n }\n }\n cursor = body.data?.next_cursor ?? undefined;\n if (!cursor) break;\n }\n const partsTotal = report.data.parts_total ?? d.total;\n const failed = Math.max(0, partsTotal - done);\n\n const finished = await fetch(reportUrl, {\n method: 'PATCH', headers: H,\n body: JSON.stringify({\n data: {\n status: failed === 0 ? 'done' : 'partial',\n total, parts_done: done, parts_failed: failed, completed_at: new Date().toISOString(),\n },\n if: { status: 'running' },\n }),\n });\n if (finished.status === 409) return Response.json({ skipped: 'already finished' });\n if (!finished.ok) return Response.json({ error: `report write: ${finished.status}` }, { status: 502 }); // another attempt (header note)\n return Response.json({\n report_id: reportId, status: failed === 0 ? 'done' : 'partial', total, parts_done: done, parts_failed: failed,\n runs: { succeeded: d.succeeded, dead_lettered: d.dead_lettered, cancelled: d.cancelled },\n });\n },\n};\n",
@@ -22505,16 +22731,22 @@ async function dashFetch(base2, method, path, body, cookie) {
22505
22731
  function makeDash(base2) {
22506
22732
  return (method, path, body, cookie) => dashFetch(base2, method, path, body, cookie);
22507
22733
  }
22508
- function linkRequest(slug, tenantId, base2) {
22734
+ function linkRequest(slug, tenantId, base2, workload) {
22509
22735
  const as = flag("as");
22510
- const env = flag("env") ?? keptEnvLabel(loadProject(), slug, tenantId, as) ?? envLabelFor(base2);
22511
- return { slug, tenantId, edgeUrl: base2, env, ...as !== void 0 ? { as } : {} };
22736
+ const { env, fromWorkload, notice } = linkEnvLabel({
22737
+ base: base2,
22738
+ envFlag: flag("env"),
22739
+ kept: keptEnvLabel(loadProject(), slug, tenantId, as),
22740
+ workload
22741
+ });
22742
+ if (notice) console.error(`vxil: ${notice}`);
22743
+ return { slug, tenantId, edgeUrl: base2, env, fromWorkload, ...as !== void 0 ? { as } : {} };
22512
22744
  }
22513
- function persistLink(slug, tenantId, apiKey, base2) {
22514
- const req = linkRequest(slug, tenantId, base2);
22745
+ function persistLink(slug, tenantId, apiKey, base2, workload) {
22746
+ const req = linkRequest(slug, tenantId, base2, workload);
22515
22747
  const plan = planLinkBinding(loadProject(), req);
22516
22748
  if ("error" in plan) fail(plan.error);
22517
- const envWarn = envLabelWarning(req.env, base2);
22749
+ const envWarn = req.fromWorkload ? null : envLabelWarning(req.env, base2);
22518
22750
  if (envWarn) console.error(`vxil: ${envWarn}`);
22519
22751
  const creds = loadCredentials();
22520
22752
  creds.keys = { ...creds.keys, [slug]: apiKey };
@@ -22968,7 +23200,7 @@ async function runApply(apply, sel = "prod", opts = {}) {
22968
23200
  const gate = !apply && !!opts.gate;
22969
23201
  const explain = !apply && !!opts.explain;
22970
23202
  const { api, target } = requireApi(sel, apply ? { write: "push" } : gate ? { exitCode: 2 } : {});
22971
- if (!apply) console.error(targetBanner(target, gate ? "diff" : "plan"));
23203
+ if (!apply) console.error(targetBanner(target, gate || opts.verb === "diff" ? "diff" : "plan"));
22972
23204
  const report = { drift: 0, ignored: 0, errors: 0 };
22973
23205
  const pushProblems = { hard: [], strict: [] };
22974
23206
  const strictPush = apply && hasFlag("strict");
@@ -23021,8 +23253,10 @@ async function runApply(apply, sel = "prod", opts = {}) {
23021
23253
  if (gate) report.errors++;
23022
23254
  }
23023
23255
  }
23256
+ let uncompared = 0;
23024
23257
  const notCompared = (u, hint) => {
23025
23258
  console.log(formatNotCompared(u, hint));
23259
+ uncompared++;
23026
23260
  if (gate) report.errors++;
23027
23261
  };
23028
23262
  const runSection = async (label, fn) => {
@@ -23143,6 +23377,7 @@ ${r.feature}.${r.datum} (declared in config):`);
23143
23377
  console.log(formatDeclaredApiState(r, { strict: strictApiState }));
23144
23378
  if (r.skipped) continue;
23145
23379
  if (r.refused) {
23380
+ uncompared++;
23146
23381
  if (gate) report.errors++;
23147
23382
  else console.error(`vxil: warning \u2014 ${r.feature}.${r.datum} not converged: ${r.refused}`);
23148
23383
  if (apply) pushProblems.strict.push(`${r.feature}.${r.datum} refused: ${r.refused}`);
@@ -23150,6 +23385,7 @@ ${r.feature}.${r.datum} (declared in config):`);
23150
23385
  }
23151
23386
  if (r.deferred) {
23152
23387
  console.error(`vxil: warning \u2014 ${r.feature}.${r.datum} not converged yet: ${r.deferred}`);
23388
+ uncompared++;
23153
23389
  if (gate) report.errors++;
23154
23390
  if (apply) pushProblems.strict.push(`${r.feature}.${r.datum} deferred: ${r.deferred}`);
23155
23391
  continue;
@@ -23311,6 +23547,7 @@ api state \u2014 ${self.target.tenantSlug ?? "(env key)"} vs ${other.target.tena
23311
23547
  for (const u of b2.unavailable) notCompared(u, `--against ${other.target.tenantSlug ?? ""}`.trim());
23312
23548
  for (const w of [...truncationWarnings(a, "the target"), ...truncationWarnings(b2, "--against")]) {
23313
23549
  console.log(w);
23550
+ uncompared++;
23314
23551
  if (gate) report.errors++;
23315
23552
  }
23316
23553
  if (a.unavailable.length || b2.unavailable.length) return;
@@ -23356,7 +23593,8 @@ vxil diff \u2192 ${report.drift} drift item(s), ${report.ignored} ignored, ${rep
23356
23593
  }
23357
23594
  process.exit(code);
23358
23595
  }
23359
- if (!apply) console.log("\n(run `vxil push` to apply)");
23596
+ if (!apply) console.log(`
23597
+ ${planFooter({ drift: report.drift, ignored: report.ignored, notCompared: uncompared }, compared)}`);
23360
23598
  if (apply && !hasFlag("no-gen")) await runGen(sel);
23361
23599
  if (apply) {
23362
23600
  const exitWith = pushExitCode(pushProblems, strictPush, target.slot === "primary" && target.tenantSlug ? { slug: target.tenantSlug } : {});
@@ -24146,7 +24384,7 @@ try {
24146
24384
  tenant_id: result.tenant_id,
24147
24385
  slug: result.slug,
24148
24386
  edge_url: base2,
24149
- env: flag("env") ?? envLabelFor(base2),
24387
+ env: flag("env") ?? TRY_ENV_LABEL,
24150
24388
  ephemeral: true,
24151
24389
  ...result.expires_at ? { expires_at: result.expires_at } : {}
24152
24390
  });
@@ -24222,7 +24460,7 @@ try {
24222
24460
  if (linkSession) {
24223
24461
  const r = await linkViaSession(dash, linkSession, slug, keyName);
24224
24462
  if ("result" in r) {
24225
- const bound = persistLink(r.result.slug, r.result.tenant_id, r.result.api_key, base2);
24463
+ const bound = persistLink(r.result.slug, r.result.tenant_id, r.result.api_key, base2, r.result.workload);
24226
24464
  console.log(`\u2713 linked '${slug}' ${bound} (minted a key via your dashboard session)`);
24227
24465
  break;
24228
24466
  }
@@ -24236,7 +24474,7 @@ try {
24236
24474
  log: (l) => console.log(l),
24237
24475
  sleep: (ms) => new Promise((r) => setTimeout(r, ms))
24238
24476
  });
24239
- const bound = persistLink(linked.slug, linked.tenant_id, linked.api_key, base2);
24477
+ const bound = persistLink(linked.slug, linked.tenant_id, linked.api_key, base2, linked.workload);
24240
24478
  console.log(`
24241
24479
  \u2713 linked '${linked.slug}' ${bound} (approved in the browser)`);
24242
24480
  }
@@ -24594,7 +24832,7 @@ try {
24594
24832
  const { project } = await withDashSession(dash, base2, (c) => createProjectViaSession(dash, c, p.body));
24595
24833
  if (jsonOut) console.log(JSON.stringify(project, null, 2));
24596
24834
  else console.log(`\u2713 created '${project.slug}' (${project.display_name}, ${project.tier}${project.workload ? `, ${project.workload}` : ""}) \u2014 no key and no features yet`);
24597
- console.error(`next: vxil link ${project.slug} --mint && vxil push (this repo's binding is unchanged)`);
24835
+ console.error(projectCreateNextHint(project.slug, project.workload));
24598
24836
  break;
24599
24837
  }
24600
24838
  if (sub === "workload") {
@@ -24849,6 +25087,7 @@ ${formatSummary(state)}`);
24849
25087
  await runApply(false, sel, {
24850
25088
  explain: hasFlag("explain"),
24851
25089
  gate: against !== void 0 || hasFlag("exit-code"),
25090
+ verb: "diff",
24852
25091
  ...hasFlag("api-state") ? { apiState: { self: selector(), against: againstSelector(against) } } : {}
24853
25092
  });
24854
25093
  break;
@@ -26426,6 +26665,10 @@ function migrateUsage() {
26426
26665
  \xB7 [--with-oauth-identities] carry the Google/Apple/GitHub/Facebook sign-in links (needs
26427
26666
  --with-password-hashes in the same run) \xB7 [--keep-timestamps] keep each row's source
26428
26667
  created_at / updated_at (cms creates; server key only)
26668
+ \xB7 [--files-owner <user id>] the owner of every migrated file (default: the path's first
26669
+ segment) \xB7 [--files-org-from-prefix] the path's first segment is the WORKSPACE (org_id):
26670
+ workspace files, kept when the uploader is erased (needs --files-owner and
26671
+ --files-org-map <file.json>: { "<source workspace id>": "<vxil org id>" })
26429
26672
  verify reconcile source vs target counts + re-links + restate the residuals (exit 1 on deltas)
26430
26673
  payments --from-provider stripe|paddle|paypal|revenuecat (--customers <file.csv> | --all) [--dry-run]
26431
26674
  backfill SUBSCRIPTIONS through the server sync leg (the project's own provider keys; resumable via
@@ -26525,6 +26768,27 @@ ${migrateUsage()}`);
26525
26768
  fail(`--file-links must be shared-link or object_id (got '${fileLinks}')`);
26526
26769
  }
26527
26770
  const oidcIssuer = flag("oidc-issuer");
26771
+ const filesOwner = flag("files-owner");
26772
+ if (hasFlag("files-org-from-prefix") && !filesOwner) {
26773
+ fail("--files-org-from-prefix needs --files-owner <user id>: the path prefix is the workspace, not the owner");
26774
+ }
26775
+ const orgMapPath = flag("files-org-map");
26776
+ let filesOrgMap;
26777
+ if (hasFlag("files-org-from-prefix") && orgMapPath === void 0) {
26778
+ fail('--files-org-from-prefix needs --files-org-map <file.json>: { "<source workspace id>": "<vxil org id>" } (vxil mints its own org ids \u2014 create each org with POST /v1/orgs first)');
26779
+ }
26780
+ if (orgMapPath !== void 0) {
26781
+ let parsed;
26782
+ try {
26783
+ parsed = JSON.parse(readFileSync8(resolve7(orgMapPath), "utf8"));
26784
+ } catch (e) {
26785
+ fail(`--files-org-map: cannot read ${orgMapPath} as JSON \u2014 ${e.message}`);
26786
+ }
26787
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.values(parsed).some((v) => typeof v !== "string" || v.length < 1 || v.length > 128)) {
26788
+ fail('--files-org-map must be a JSON object of "<source workspace id>": "<vxil org id>" strings');
26789
+ }
26790
+ filesOrgMap = parsed;
26791
+ }
26528
26792
  const results = await runMigrateData({ api, adapter, cwd, log }, {
26529
26793
  execute: hasFlag("execute"),
26530
26794
  resume: hasFlag("resume"),
@@ -26533,6 +26797,9 @@ ${migrateUsage()}`);
26533
26797
  ...hasFlag("with-oauth-identities") ? { withOauthIdentities: true } : {},
26534
26798
  ...hasFlag("keep-timestamps") ? { keepTimestamps: true } : {},
26535
26799
  ...fileLinks !== void 0 ? { fileLinks } : {},
26800
+ ...filesOwner !== void 0 ? { filesOwner } : {},
26801
+ ...hasFlag("files-org-from-prefix") ? { filesOrgFromPrefix: true } : {},
26802
+ ...filesOrgMap ? { filesOrgMap } : {},
26536
26803
  ...only?.length ? { only } : {},
26537
26804
  ...hasFlag("assume-schema") ? { assumeSchema: true } : {},
26538
26805
  ...since !== void 0 ? { since } : {},