@vxil/cli 0.24.2 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/vxil-main.js CHANGED
@@ -3182,7 +3182,8 @@ var API_STATE_DATUMS = [
3182
3182
  { feature: "rate-limits", datum: "policies" },
3183
3183
  { feature: "webhooks", datum: "subscriptions" },
3184
3184
  { feature: "ai", datum: "templates" },
3185
- { feature: "orgs", datum: "roles" }
3185
+ { feature: "orgs", datum: "roles" },
3186
+ { feature: "vector-search", datum: "collections" }
3186
3187
  ];
3187
3188
  function apiStateDatumOf(feature) {
3188
3189
  return API_STATE_DATUMS.find((d) => d.feature === feature)?.datum ?? null;
@@ -3311,6 +3312,110 @@ function planOrgRoles(declared, live) {
3311
3312
  for (const l of live) if (!declaredKeys.has(l.role_key)) plan.undeclared.push(l.role_key);
3312
3313
  return plan;
3313
3314
  }
3315
+ var KEYED_PROVIDERS = /* @__PURE__ */ new Set(["openai", "cohere"]);
3316
+ function planSearchCollections(declared, live) {
3317
+ const plan = { create: [], update: [], unchanged: [], immutable: [], refused: [], undeclared: [] };
3318
+ const byName = new Map(live.map((l) => [l.collection, l]));
3319
+ const declaredNames = new Set(declared.map((d) => d.collection));
3320
+ for (const d of declared) {
3321
+ const l = byName.get(d.collection);
3322
+ if (!l) {
3323
+ plan.create.push({
3324
+ collection: d.collection,
3325
+ ...d.dimensions !== void 0 ? { dimensions: d.dimensions } : {},
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`
3336
+ });
3337
+ continue;
3338
+ }
3339
+ if (d.dimensions !== void 0 && d.dimensions !== l.dimensions) {
3340
+ plan.immutable.push({ collection: d.collection, declared: d.dimensions, live: l.dimensions });
3341
+ continue;
3342
+ }
3343
+ const e = d.embed ?? {};
3344
+ const changed = [];
3345
+ const providerChanged = e.provider !== void 0 && e.provider !== (l.provider ?? null);
3346
+ if (providerChanged) changed.push("provider");
3347
+ if (e.model !== void 0 && l.model !== void 0 && e.model !== (l.model ?? null)) changed.push("model");
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");
3350
+ if (!changed.length) {
3351
+ plan.unchanged.push(d.collection);
3352
+ continue;
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
+ }
3362
+ let embed;
3363
+ if (providerChanged) {
3364
+ const missing = KEYED_PROVIDERS.has(e.provider) ? ["model", "apiKeyRef"].filter((k) => e[k] === void 0) : [];
3365
+ if (missing.length) {
3366
+ plan.refused.push({
3367
+ collection: d.collection,
3368
+ error: `provider change to ${e.provider} must also declare embed.${missing.join(" and embed.")} \u2014 the live ${l.provider ?? "default"} settings are never carried over to a new provider (and the new model must output the collection's ${l.dimensions} dimensions)`
3369
+ });
3370
+ continue;
3371
+ }
3372
+ embed = { ...e };
3373
+ } else {
3374
+ const provider = e.provider ?? l.provider ?? void 0;
3375
+ const model = e.model ?? l.model ?? void 0;
3376
+ const apiKeyRef = e.apiKeyRef ?? l.api_key_ref ?? void 0;
3377
+ embed = { ...provider ? { provider } : {}, ...model ? { model } : {}, ...apiKeyRef ? { apiKeyRef } : {} };
3378
+ }
3379
+ const reembed = (changed.includes("provider") || changed.includes("model")) && (l.documents ?? 1) > 0;
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
+ });
3390
+ }
3391
+ for (const l of live) if (!declaredNames.has(l.collection)) plan.undeclared.push(l.collection);
3392
+ return plan;
3393
+ }
3394
+ function searchCollectionImmutableError(i) {
3395
+ return `422 dimensions_immutable: declared dimensions ${i.declared} but the live collection has ${i.live} \u2014 dimensions are immutable; declare a new collection name and re-ingest`;
3396
+ }
3397
+ function pulledSearchCollections(rows) {
3398
+ if (rows.length > SEARCH_COLLECTION_PULL_CAP) {
3399
+ return { note: ` vector-search.collections: the tenant has ${rows.length} search collections, past the ${SEARCH_COLLECTION_PULL_CAP}-entry cap of a declared collections[] \u2014 not written; they stay live rows` };
3400
+ }
3401
+ return {
3402
+ collections: rows.map((r) => {
3403
+ const embed = {};
3404
+ if (typeof r.provider === "string") embed.provider = r.provider;
3405
+ if (typeof r.model === "string") embed.model = r.model;
3406
+ if (typeof r.api_key_ref === "string") embed.apiKeyRef = r.api_key_ref;
3407
+ return {
3408
+ collection: String(r.collection),
3409
+ // the server only ever stores an allowed width
3410
+ ...typeof r.dimensions === "number" ? { dimensions: r.dimensions } : {},
3411
+ ...Object.keys(embed).length ? { embed } : {},
3412
+ ...r.keyword_normalize === "arabic" ? { keywordNormalize: "arabic" } : {},
3413
+ ...r.org_scoped === true ? { orgScoped: true } : {}
3414
+ };
3415
+ })
3416
+ };
3417
+ }
3418
+ var SEARCH_COLLECTION_PULL_CAP = 50;
3314
3419
  function emptyApiStateSummary(datum) {
3315
3420
  return {
3316
3421
  datum,
@@ -3355,10 +3460,13 @@ function formatApiStateDetail(s) {
3355
3460
  for (const k of s.updated) lines.push(` ~ ${k}`);
3356
3461
  for (const k of s.recreated) lines.push(` \u21BB ${k} (recreated \u2014 new id)`);
3357
3462
  for (const k of s.deleted) lines.push(` - ${k} (deleted: undeclared, --allow-destructive)`);
3358
- for (const k of s.undeclared) lines.push(` \xB7 ${k} \u2014 live but not declared (left in place; --allow-destructive deletes it)`);
3463
+ for (const k of s.undeclared) {
3464
+ lines.push(s.datum === "collections" ? ` \xB7 ${k} \u2014 live but not declared (left in place; a push never deletes a search collection)` : ` \xB7 ${k} \u2014 live but not declared (left in place; --allow-destructive deletes it)`);
3465
+ }
3359
3466
  for (const k of s.pending_destructive) lines.push(` ! ${k} \u2014 needs delete+recreate (new id); re-run with --allow-destructive`);
3360
3467
  for (const k of s.ambiguous) lines.push(` ! ${k} \u2014 more than one live row carries this name; delete the extras first`);
3361
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}`);
3362
3470
  return lines;
3363
3471
  }
3364
3472
 
@@ -3369,6 +3477,9 @@ var PUBLIC_ASSET_TYPES = {
3369
3477
  webp: "image/webp",
3370
3478
  avif: "image/avif",
3371
3479
  gif: "image/gif",
3480
+ // Favicons. A raster container (BMP/PNG frames) — no
3481
+ // script, rendered only as an image; never a variant source.
3482
+ ico: "image/x-icon",
3372
3483
  mp4: "video/mp4",
3373
3484
  webm: "video/webm",
3374
3485
  mp3: "audio/mpeg",
@@ -3382,6 +3493,7 @@ var TYPE_TO_EXT = {
3382
3493
  ...Object.fromEntries(Object.entries(PUBLIC_ASSET_TYPES).map(([ext, ct]) => [ct, ext])),
3383
3494
  "image/jpg": "jpg",
3384
3495
  "image/pjpeg": "jpg",
3496
+ "image/vnd.microsoft.icon": "ico",
3385
3497
  "audio/mp3": "mp3",
3386
3498
  "audio/x-m4a": "m4a",
3387
3499
  "audio/m4a": "m4a",
@@ -3917,7 +4029,17 @@ var AuthConfigSchema = Type.Object({
3917
4029
  // OPTIONAL bag = 1 leaf. Lives in AUTH config (not orgs) because auth-v1's
3918
4030
  // CONFIG_KV is the auth namespace and cannot see orgs config.
3919
4031
  orgClaims: Type.Optional(Type.Object({
3920
- enabled: Type.Boolean({ default: false })
4032
+ enabled: Type.Boolean({ default: false }),
4033
+ // ACTIVE-ORG mode. Every mint/refresh stamps the session claim
4034
+ // `oam: 1`, the session row stores the org it acts in (active_org_id: the
4035
+ // most-recent membership at first mint, or the org picked by
4036
+ // POST /v1/auth/sessions/refresh { org_id }), a refresh never falls back to
4037
+ // another org, the org role is no longer folded into `roles`, membership
4038
+ // changes mark live sessions `claims_stale`, and orgs membership writes are
4039
+ // server-only for end users. Needs `enabled`. Optional WITHOUT a default
4040
+ // (absent = off) so existing manifests fold byte-identically; inside the
4041
+ // existing bag, so no new config leaf.
4042
+ activeOrg: Type.Optional(Type.Boolean())
3921
4043
  })),
3922
4044
  // Note: the earlier `redirects` block was DROPPED entirely —
3923
4045
  // it had zero consumers (grep-verified: no worker reads config.redirects) and
@@ -3935,8 +4057,11 @@ var AuthConfigSchema = Type.Object({
3935
4057
  // chars leave the worker, never the password) at sign-up / reset-confirm;
3936
4058
  // fail-OPEN on network error → 422 password_breached.
3937
4059
  // • captchaSecretRef — a secret-store ref (feature 'auth') holding the
3938
- // Turnstile secret; when set, sign-up / OTP request / magic-link request
3939
- // require `captcha_token` (403 captcha_failed otherwise).
4060
+ // Turnstile secret; when set, sign-up / OTP request / magic-link request /
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).
3940
4065
  // • allowedRedirectOrigins — when non-empty, EVERY caller-supplied
3941
4066
  // redirect/return URL (magic link, password reset, email verification,
3942
4067
  // OAuth start) must match one origin exactly → 422 redirect_not_allowed.
@@ -3951,6 +4076,9 @@ var AuthConfigSchema = Type.Object({
3951
4076
  })),
3952
4077
  breachedPasswords: Type.Boolean({ default: false }),
3953
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()),
3954
4082
  allowedRedirectOrigins: Type.Optional(Type.Array(
3955
4083
  Type.String({ minLength: 8, maxLength: 253, pattern: REDIRECT_ORIGIN_PATTERN }),
3956
4084
  { maxItems: 32 }
@@ -3971,6 +4099,14 @@ var AuthConfigSchema = Type.Object({
3971
4099
  // account is never affected. The generic OIDC issuer keeps its own gate
3972
4100
  // (providers.oidc.allowedDomains + IdP assignment). auth-v1 signupGate.ts.
3973
4101
  inviteOnly: Type.Optional(Type.Boolean()),
4102
+ // Squatted-address defence (guide ch. 9): a verify-link confirm with NO
4103
+ // matching session proves the mailbox only. With verifyLinkEvicts:true it
4104
+ // runs the eviction guard (clears a never-verified row's password, revokes
4105
+ // its sessions, drops its provider links — the squatter defence); absent /
4106
+ // false keeps the password, so a genuine sign-up who opens the link in a
4107
+ // mail-app browser (or an SDK that sends no session_token) is not locked
4108
+ // out. Optional, NO default: every existing manifest folds byte-identically.
4109
+ verifyLinkEvicts: Type.Optional(Type.Boolean()),
3974
4110
  allowedEmailDomains: Type.Optional(Type.Array(
3975
4111
  Type.String({ minLength: 3, maxLength: 253, pattern: "^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$" }),
3976
4112
  { maxItems: 32 }
@@ -4066,7 +4202,24 @@ var FilesConfigSchema = Type.Object({
4066
4202
  // an object to the cache-forever public host (POST /v1/files/{id}/publish).
4067
4203
  // ONE optional bag: { enabled, corsOrigins, variants }. The published-bytes
4068
4204
  // ceiling is a PLAN line (control-plane plans.ts), never a tenant knob.
4069
- 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
+ }))
4070
4223
  });
4071
4224
  var DeclaredWebhookSubscriptionSchema = Type.Object({
4072
4225
  target_url: Type.String({ minLength: 9, maxLength: 2e3 }),
@@ -4306,7 +4459,10 @@ var DeclaredOrgRoleSchema = Type.Object({
4306
4459
  });
4307
4460
  var OrgsConfigSchema = Type.Object({
4308
4461
  enabled: Type.Boolean({ default: true }),
4309
- maxOrgsPerTenant: Type.Integer({ default: 100, minimum: 1, maximum: 1e4 }),
4462
+ // Org cap: max 100,000 (was 10,000); the default stays 100 so
4463
+ // existing manifests fold byte-identically. The unpaged GET /v1/orgs page is
4464
+ // clamped at 10,000 (orgs-v1 parseOrgsPage), so no response grows.
4465
+ maxOrgsPerTenant: Type.Integer({ default: 100, minimum: 1, maximum: 1e5 }),
4310
4466
  maxMembersPerOrg: Type.Integer({ default: 1e3, minimum: 1, maximum: 1e5 }),
4311
4467
  invitationTtlHours: Type.Integer({ default: 168, minimum: 1, maximum: 720 }),
4312
4468
  // Declared API state (G-31): the tenant's CUSTOM role catalog. Converged by
@@ -4387,6 +4543,28 @@ var ActivityFeedConfigSchema = Type.Object({
4387
4543
  // per-tenant request meter are the real bounds). Re-declare it together with
4388
4544
  // the binding that enforces it.
4389
4545
  });
4546
+ var SEARCH_COLLECTION_DECLARED_CAP = 50;
4547
+ var SEARCH_COLLECTION_DIMS = [256, 384, 512, 768, 1024, 1536, 3072];
4548
+ var DeclaredSearchCollectionSchema = Type.Object({
4549
+ collection: Type.String({ pattern: "^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$" }),
4550
+ dimensions: Type.Optional(Type.Union(SEARCH_COLLECTION_DIMS.map((d) => Type.Literal(d)))),
4551
+ embed: Type.Optional(Type.Object({
4552
+ provider: Type.Optional(Type.Union([
4553
+ Type.Literal("byov"),
4554
+ Type.Literal("mock"),
4555
+ Type.Literal("openai"),
4556
+ Type.Literal("cohere")
4557
+ ])),
4558
+ model: Type.Optional(Type.String({ maxLength: 128 })),
4559
+ apiKeyRef: Type.Optional(Type.String({ maxLength: 128 }))
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())
4567
+ });
4390
4568
  var VectorSearchConfigSchema = Type.Object({
4391
4569
  enabled: Type.Boolean({ default: true }),
4392
4570
  // 'auto' resolves to the default managed vector backend for the tier.
@@ -4474,7 +4652,14 @@ var VectorSearchConfigSchema = Type.Object({
4474
4652
  }),
4475
4653
  { maxItems: 8 }
4476
4654
  )
4477
- )
4655
+ ),
4656
+ // Declared API state: the tenant's search
4657
+ // collections, converged by `collection` name by `vxil push` / POST
4658
+ // /v1/apply (control-plane handlers/apiState.ts) through POST
4659
+ // /v1/search/collections. Undeclared live collections are left and
4660
+ // reported — there is no collection delete route, so never deleted.
4661
+ // Absent = not managed by config. ARRAY = ONE leaf.
4662
+ collections: Type.Optional(Type.Array(DeclaredSearchCollectionSchema, { maxItems: SEARCH_COLLECTION_DECLARED_CAP }))
4478
4663
  });
4479
4664
  var AI_TEMPLATE_NAME_PATTERN = "^[a-zA-Z0-9_.\\-]+$";
4480
4665
  var AI_MAX_DECLARED_TEMPLATES = 50;
@@ -4854,6 +5039,26 @@ var FunctionsConfigSchema = Type.Object({
4854
5039
  { enabled: Type.Boolean({ default: false }) },
4855
5040
  { default: {} }
4856
5041
  ),
5042
+ // FN-LOG-DRAIN (2026-10-12): forward this project's function log lines, in
5043
+ // signed batches, to the tenant's own https endpoint (functions-tail →
5044
+ // LogDrainDO). ONE optional leaf. The ONLY member of this deploy-managed
5045
+ // manifest a config PUT may change (handlers/functionsLogDrain.ts) — `vxil
5046
+ // push` carries it from `features.functions.logDrain`. `dailyLines` is
5047
+ // SERVER-SET from the plan at write time (LOG_DRAIN_LINES_PER_DAY_BY_TIER);
5048
+ // an authored value is overwritten. `secretRef` names a functions secret
5049
+ // (`vxil secrets set functions/<name>`), the HMAC key of the signature.
5050
+ logDrain: Type.Optional(Type.Object({
5051
+ url: Type.String({ minLength: 9, maxLength: 2048 }),
5052
+ secretRef: Type.String({ pattern: "^[a-z0-9_]{1,64}$" }),
5053
+ minLevel: Type.Optional(Type.Union([
5054
+ Type.Literal("debug"),
5055
+ Type.Literal("info"),
5056
+ Type.Literal("log"),
5057
+ Type.Literal("warn"),
5058
+ Type.Literal("error")
5059
+ ])),
5060
+ dailyLines: Type.Optional(Type.Integer({ minimum: 0 }))
5061
+ })),
4857
5062
  // ONE Type.Record leaf — the per-function object is DATA, not flags.
4858
5063
  functions: Type.Optional(
4859
5064
  Type.Record(
@@ -5103,6 +5308,168 @@ var FEATURE_KEYS = [
5103
5308
  "functions",
5104
5309
  "copilot"
5105
5310
  ];
5311
+ var CMS_RETAIN_DAYS_MAX = 3650;
5312
+ function isDeclaredRetainDays(v) {
5313
+ return v === void 0 || v === null || typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= CMS_RETAIN_DAYS_MAX;
5314
+ }
5315
+ function retainDaysShortens(declared, remote) {
5316
+ if (declared === null) return false;
5317
+ return remote === null || declared < remote;
5318
+ }
5319
+
5320
+ // ../config/src/index.ts
5321
+ var CMS_ROLE_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
5322
+ var CMS_ORG_READ_ROLES_MAX = 16;
5323
+ var ORG_FIELD_SLOTS = /* @__PURE__ */ new Set(["s1", "s2", "s3", "s4"]);
5324
+ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5325
+ function declaredOrgField(def) {
5326
+ if (!isObj(def)) return null;
5327
+ const v = def.orgField ?? def.org_field;
5328
+ return typeof v === "string" && v.length > 0 ? v : null;
5329
+ }
5330
+ function workspaceCollectionErrors(name, def) {
5331
+ if (!isObj(def)) return [];
5332
+ const errs = [];
5333
+ const at = `cms.collections.${name}`;
5334
+ const camel2 = def.orgField;
5335
+ const snake = def.org_field;
5336
+ if (camel2 !== void 0 && snake !== void 0 && camel2 !== snake) {
5337
+ errs.push(`${at} sets orgField and org_field to different values \u2014 send one`);
5338
+ }
5339
+ const rawOrg = camel2 ?? snake;
5340
+ let orgField = null;
5341
+ if (rawOrg !== void 0 && rawOrg !== null) {
5342
+ if (typeof rawOrg !== "string" || rawOrg.length === 0) {
5343
+ errs.push(`${at}.orgField must be a field name (got ${JSON.stringify(rawOrg)})`);
5344
+ } else {
5345
+ orgField = rawOrg;
5346
+ const f = isObj(def.fields) ? def.fields[rawOrg] : void 0;
5347
+ if (!isObj(f)) {
5348
+ errs.push(`${at}.orgField '${rawOrg}' names no declared field`);
5349
+ } else {
5350
+ if (f.type !== "string") {
5351
+ errs.push(`${at}.orgField '${rawOrg}' must be a string field (is ${JSON.stringify(f.type)})`);
5352
+ }
5353
+ const slot = f.indexSlot ?? f.index_slot;
5354
+ if (typeof slot !== "string" || !ORG_FIELD_SLOTS.has(slot)) {
5355
+ errs.push(`${at}.orgField '${rawOrg}' needs an indexSlot in s1\u2013s4 (every end-user read filters on it)`);
5356
+ }
5357
+ }
5358
+ }
5359
+ }
5360
+ const owner = def.ownerField ?? def.owner_field;
5361
+ if (orgField !== null && owner === orgField) {
5362
+ errs.push(`${at}.orgField and ownerField must name different fields (both are '${orgField}')`);
5363
+ }
5364
+ const rrCamel = def.orgReadRoles;
5365
+ const rrSnake = def.org_read_roles;
5366
+ if (rrCamel !== void 0 && rrSnake !== void 0 && JSON.stringify(rrCamel) !== JSON.stringify(rrSnake)) {
5367
+ errs.push(`${at} sets orgReadRoles and org_read_roles to different values \u2014 send one`);
5368
+ }
5369
+ const rr = rrCamel ?? rrSnake;
5370
+ if (rr !== void 0 && rr !== null) {
5371
+ if (!Array.isArray(rr) || rr.some((r) => typeof r !== "string" || !CMS_ROLE_SLUG_RE.test(r))) {
5372
+ errs.push(`${at}.orgReadRoles must be an array of role slugs matching ${CMS_ROLE_SLUG_RE} (got ${JSON.stringify(rr)})`);
5373
+ } else if (rr.length > CMS_ORG_READ_ROLES_MAX) {
5374
+ errs.push(`${at}.orgReadRoles has ${rr.length} entries \u2014 at most ${CMS_ORG_READ_ROLES_MAX}`);
5375
+ } else if (rr.length > 0 && orgField === null) {
5376
+ errs.push(`${at}.orgReadRoles needs orgField (it gates reads of a workspace-scoped collection)`);
5377
+ }
5378
+ }
5379
+ return errs;
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
+ };
5387
+ var asList = (v) => Array.isArray(v) ? v : v === void 0 || v === null ? [] : [v];
5388
+ var stringsOf = (v) => asList(v).filter((s) => typeof s === "string");
5389
+ function workspaceManifestErrors(config, opts = {}) {
5390
+ const errs = [];
5391
+ const rawCollections = config.cms?.collections;
5392
+ const collections = isObj(rawCollections) ? rawCollections : {};
5393
+ const orgCols = /* @__PURE__ */ new Map();
5394
+ for (const [name, def] of Object.entries(collections)) {
5395
+ const of = declaredOrgField(def);
5396
+ if (of) orgCols.set(name, of);
5397
+ }
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;
5409
+ const names = [...orgCols.keys()].join(", ");
5410
+ const auth = isObj(features.auth) ? features.auth : {};
5411
+ const oc = isObj(auth.orgClaims) ? auth.orgClaims : {};
5412
+ if (opts.checkAuth !== false && (oc.enabled !== true || oc.activeOrg !== true)) {
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
+ }
5419
+ }
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`);
5428
+ }
5429
+ }
5430
+ asList(vs.sync).forEach((s, i) => {
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`);
5440
+ }
5441
+ });
5442
+ for (const [rmName, rm] of Object.entries(isObj(cms.readModels) ? cms.readModels : {})) {
5443
+ if (!isObj(rm) || typeof rm.collection !== "string") continue;
5444
+ const srcOrg = orgCols.get(rm.collection);
5445
+ const to = isObj(rm.materialize) ? rm.materialize.to : void 0;
5446
+ if (!srcOrg || typeof to !== "string") continue;
5447
+ const tOrg = orgCols.get(to);
5448
+ const spec = isObj(rm.spec) ? rm.spec : {};
5449
+ const groups = [...stringsOf(spec.groupBy), ...stringsOf(spec.partitionBy)];
5450
+ if (tOrg !== srcOrg || !groups.includes(srcOrg)) {
5451
+ errs.push(`features.cms.readModels.${rmName}: materializes workspace-scoped '${rm.collection}' into '${to}' \u2014 the target must be an orgField collection with orgField '${srcOrg}' and the read model must group by '${srcOrg}', or one workspace's rollup rows land where every end user can read them`);
5452
+ }
5453
+ }
5454
+ const orgs = isObj(features.orgs) ? features.orgs : {};
5455
+ const longRoles = new Set(asList(orgs.roles).map((r) => isObj(r) ? r.role_key : void 0).filter((k) => typeof k === "string" && k.length > 32));
5456
+ for (const [name] of orgCols) {
5457
+ const def = collections[name];
5458
+ const gate = /* @__PURE__ */ new Set([
5459
+ ...stringsOf(def.orgReadRoles ?? def.org_read_roles),
5460
+ ...stringsOf(def.writeRoles ?? def.write_roles)
5461
+ ]);
5462
+ for (const f of Object.values(isObj(def.fields) ? def.fields : {})) {
5463
+ if (isObj(f)) for (const r of stringsOf(f.readRoles ?? f.read_roles)) gate.add(r);
5464
+ }
5465
+ for (const r of gate) {
5466
+ if (r.length > 32 || longRoles.has(r)) {
5467
+ errs.push(`cms.collections.${name}: gate role '${r}' is longer than 32 characters \u2014 an orgs role named in a cms gate must be 32 characters or fewer (longer keys stay legal in orgs)`);
5468
+ }
5469
+ }
5470
+ }
5471
+ return errs;
5472
+ }
5106
5473
 
5107
5474
  // src/lib.ts
5108
5475
  var MAX_FN_BINDINGS = 8;
@@ -5169,9 +5536,9 @@ function lowerAllTriggerBindings(def, name) {
5169
5536
  return bindings;
5170
5537
  }
5171
5538
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
5172
- var VXIL_CONFIG_PKG_VERSION = "0.17.0";
5173
- var VXIL_SDK_PKG_VERSION = "0.24.1";
5174
- var VXIL_CLI_PKG_VERSION = "0.24.2";
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.0";
5175
5542
  function ensureScaffoldPackageJson(cwd, opts = {}) {
5176
5543
  const file = resolve(cwd, "package.json");
5177
5544
  const wanted = {
@@ -5335,6 +5702,8 @@ async function loadVxilConfig(cwd = process.cwd()) {
5335
5702
  throw new Error(`${found}: \`features\` must be an object`);
5336
5703
  }
5337
5704
  if (typeof cfg.apiVersion === "string") loadedConfigApiVersion = cfg.apiVersion;
5705
+ const wsErrs = workspaceManifestErrors(cfg);
5706
+ if (wsErrs.length) throw new Error(`${found}: ${wsErrs.join("; ")}`);
5338
5707
  return cfg;
5339
5708
  }
5340
5709
  function diffManifest(prev, next, prefix = "") {
@@ -5353,6 +5722,19 @@ function diffManifest(prev, next, prefix = "") {
5353
5722
  }
5354
5723
  return out;
5355
5724
  }
5725
+ var ACTIVE_ORG_MIN_SDK = "0.25.0";
5726
+ function activeOrgEnableWarning(feature, changes) {
5727
+ if (feature !== "auth") return null;
5728
+ const target = ["orgClaims", "activeOrg"];
5729
+ const at = (v, rest2) => rest2.reduce((o, k) => o && typeof o === "object" && !Array.isArray(o) ? o[k] : void 0, v);
5730
+ const turnsOn = changes.some((c) => {
5731
+ const segs = c.path === "" ? [] : c.path.split(".");
5732
+ if (segs.length > target.length || segs.some((k, i) => k !== target[i])) return false;
5733
+ const rest2 = target.slice(segs.length);
5734
+ return at(c.to, rest2) === true && at(c.from, rest2) !== true;
5735
+ });
5736
+ return turnsOn ? `auth orgClaims.activeOrg turns on: apps must use @vxil/sdk ${ACTIVE_ORG_MIN_SDK} or later \u2014 older SDKs do not recover from 401 session_stale after an org membership or role change (see guide 06, orgs)` : null;
5737
+ }
5356
5738
  function formatDiff(changes) {
5357
5739
  if (!changes.length) return " (no changes)";
5358
5740
  return changes.map(({ path, from, to }) => {
@@ -5415,7 +5797,6 @@ async function mapPool(items, limit, fn) {
5415
5797
  }
5416
5798
  function assembleApplyBundle(cfg, fnSources) {
5417
5799
  const { functions: _fnFeature, ...configFeatures } = cfg.features;
5418
- void _fnFeature;
5419
5800
  const functions = Object.entries(cfg.functions ?? {}).map(([name, def]) => {
5420
5801
  const source = fnSources[name];
5421
5802
  if (source === void 0) throw new Error(`functions: no bundled source for '${name}'`);
@@ -5434,8 +5815,11 @@ function assembleApplyBundle(cfg, fnSources) {
5434
5815
  ...limits ? { limits } : {}
5435
5816
  };
5436
5817
  });
5818
+ const fnFeature = _fnFeature;
5819
+ const features = { ...configFeatures };
5820
+ if (fnFeature && typeof fnFeature === "object") features.functions = { logDrain: fnFeature.logDrain ?? null };
5437
5821
  return {
5438
- ...Object.keys(configFeatures).length ? { features: configFeatures } : {},
5822
+ ...Object.keys(features).length ? { features } : {},
5439
5823
  ...cfg.cms?.collections && Object.keys(cfg.cms.collections).length ? { cms: { collections: cfg.cms.collections } } : {},
5440
5824
  ...functions.length ? { functions } : {}
5441
5825
  };
@@ -5593,6 +5977,40 @@ async function planOrPush({ api, features, apply, allowDestructive }) {
5593
5977
  }
5594
5978
  return results;
5595
5979
  }
5980
+ function sameLogDrain(want, have) {
5981
+ const norm = (d) => {
5982
+ if (!d || typeof d !== "object") return null;
5983
+ const o = d;
5984
+ return JSON.stringify([o.url, String(o.secretRef ?? "").replace(/^secret:/, ""), o.minLevel ?? "warn"]);
5985
+ };
5986
+ return norm(want) === norm(have);
5987
+ }
5988
+ async function planOrPushLogDrain({ api, fnFeature, apply }) {
5989
+ if (!fnFeature || typeof fnFeature !== "object") return null;
5990
+ const to = fnFeature.logDrain ?? null;
5991
+ const cur = await api("GET", "/v1/config/functions");
5992
+ const unavailable = readUnavailable("GET /v1/config/functions", cur);
5993
+ if (unavailable) {
5994
+ if (apply) {
5995
+ throw new Error(`functions.logDrain: cannot read ${unavailable.route} (${unavailable.code ?? unavailable.status}) \u2014 refusing to push against a remote it could not read`);
5996
+ }
5997
+ return { version: 0, from: null, to, changed: false, applied: null, remoteUnavailable: unavailable };
5998
+ }
5999
+ const exists = cur.status === 200;
6000
+ const version = exists ? Number(cur.body.data?.version ?? 0) : 0;
6001
+ const from = exists ? cur.body.data?.manifest?.logDrain ?? null : null;
6002
+ const changed = !sameLogDrain(to, from);
6003
+ let applied = null;
6004
+ if (apply && changed) {
6005
+ const put = await api("PUT", "/v1/config/functions", { logDrain: to }, version > 0 ? { "if-match": String(version) } : {});
6006
+ if (put.status !== 200) {
6007
+ const e = put.body.error ?? {};
6008
+ throw new Error(`push failed for functions.logDrain: ${e.code ?? put.status} ${e.message ?? ""} ${e.hint ?? ""}`);
6009
+ }
6010
+ applied = put.body.data?.version ?? null;
6011
+ }
6012
+ return { version, from, to, changed, applied };
6013
+ }
5596
6014
  function classifyRead(status, code) {
5597
6015
  if (status === 200) return "ok";
5598
6016
  if (status === 404) return "empty";
@@ -8126,6 +8544,35 @@ function egressDecision(targetUrl, allowHosts = []) {
8126
8544
  return { allow: false, reason: `not-allowlisted:${host}` };
8127
8545
  }
8128
8546
 
8547
+ // ../runtime/src/logDrain.ts
8548
+ var LOG_DRAIN_SECRET_NAME_RE = /^[a-z0-9_]{1,64}$/;
8549
+ var LOG_DRAIN_LEVELS = /* @__PURE__ */ new Set(["debug", "info", "log", "warn", "error"]);
8550
+ var LOG_DRAIN_SHAPE_HINT = 'logDrain needs { url: "https://\u2026", secretRef: "<functions secret name>", minLevel?: "debug"|"info"|"log"|"warn"|"error" } (or null to remove it)';
8551
+ function logDrainUrlError(url) {
8552
+ let host;
8553
+ try {
8554
+ host = new URL(url).hostname;
8555
+ } catch {
8556
+ return "logDrain.url is not a URL";
8557
+ }
8558
+ const d = egressDecision(url, [host]);
8559
+ if (!d.allow) return `logDrain.url is refused by the egress guard (${d.reason})`;
8560
+ if (d.reason === "vxil-api") return "logDrain.url must be your own endpoint, not the vxil API";
8561
+ return null;
8562
+ }
8563
+ function logDrainShapeError(drain) {
8564
+ if (drain === null) return null;
8565
+ const d = drain;
8566
+ if (!d || typeof d !== "object" || Array.isArray(d) || typeof d.url !== "string" || typeof d.secretRef !== "string") {
8567
+ return LOG_DRAIN_SHAPE_HINT;
8568
+ }
8569
+ if (!LOG_DRAIN_SECRET_NAME_RE.test(d.secretRef.replace(/^secret:/, ""))) {
8570
+ return `logDrain.secretRef must be a functions secret name matching ${LOG_DRAIN_SECRET_NAME_RE.source}`;
8571
+ }
8572
+ if (d.minLevel !== void 0 && !LOG_DRAIN_LEVELS.has(d.minLevel)) return LOG_DRAIN_SHAPE_HINT;
8573
+ return logDrainUrlError(d.url);
8574
+ }
8575
+
8129
8576
  // ../runtime/src/jobsCallbackSig.ts
8130
8577
  var JOBS_SIG_HEADER = "X-Vxil-Jobs-Signature";
8131
8578
  function signedString(runId, tenantId, issuedAt, bodyHashHex) {
@@ -8309,7 +8756,8 @@ var DRIFT_SECTION = {
8309
8756
  policies: "policy",
8310
8757
  subscriptions: "subscription",
8311
8758
  templates: "template",
8312
- roles: "role"
8759
+ roles: "role",
8760
+ collections: "search-collection"
8313
8761
  };
8314
8762
  function declaredDriftKey(datum, key) {
8315
8763
  return `api:${DRIFT_SECTION[datum]}:${key}`;
@@ -8318,7 +8766,8 @@ var DATUM_READ_SCOPE = {
8318
8766
  policies: "ratelimits:read",
8319
8767
  subscriptions: "webhooks:read",
8320
8768
  templates: "ai:read",
8321
- roles: "orgs:read"
8769
+ roles: "orgs:read",
8770
+ collections: "vector-search:read"
8322
8771
  };
8323
8772
  function declaredApiStateBlocks(features) {
8324
8773
  const out = [];
@@ -8337,6 +8786,7 @@ function summaryOf(datum, src) {
8337
8786
  if (Array.isArray(v)) summary[k].push(...v);
8338
8787
  }
8339
8788
  if (Array.isArray(src?.failed)) summary.failed.push(...src.failed);
8789
+ if (Array.isArray(src?.notes) && src.notes.length) summary.notes = [...src.notes];
8340
8790
  return summary;
8341
8791
  }
8342
8792
  function base(block, source) {
@@ -8357,7 +8807,8 @@ var ROUTE = {
8357
8807
  policies: "/v1/rate-limits/policies",
8358
8808
  subscriptions: "/v1/webhooks/subscriptions",
8359
8809
  templates: "/v1/ai/templates",
8360
- roles: "/v1/orgs/roles"
8810
+ roles: "/v1/orgs/roles",
8811
+ collections: "/v1/search/collections"
8361
8812
  };
8362
8813
  function listOf(v) {
8363
8814
  return Array.isArray(v) ? v : [];
@@ -8422,6 +8873,14 @@ async function readOnlyPlan(api, block, allowDestructive) {
8422
8873
  s.updated.push(...plan.update.map((u) => u.role.role_key));
8423
8874
  s.unchanged.push(...plan.unchanged);
8424
8875
  destructive([], plan.undeclared);
8876
+ } else if (datum === "collections") {
8877
+ const plan = planSearchCollections(manifest.collections, listOf(data2.collections));
8878
+ s.created.push(...plan.create.map((c) => c.collection));
8879
+ for (const u of plan.update) (u.reembed && !allowDestructive ? s.pending_destructive : s.updated).push(u.body.collection);
8880
+ s.unchanged.push(...plan.unchanged);
8881
+ s.undeclared.push(...plan.undeclared);
8882
+ for (const i of plan.immutable) s.failed.push({ key: i.collection, error: searchCollectionImmutableError(i) });
8883
+ for (const r of plan.refused) s.failed.push({ key: r.collection, error: r.error });
8425
8884
  } else {
8426
8885
  const hashed = await Promise.all(manifest.templates.map(async (t) => ({
8427
8886
  template: t.template,
@@ -9055,6 +9514,12 @@ function writeRolesWidens(declared, remote) {
9055
9514
  if (declared.length === 0) return true;
9056
9515
  return declared.some((r) => !remote.includes(r));
9057
9516
  }
9517
+ function orgFieldWidens(declared, remote) {
9518
+ return remote !== null && declared !== remote;
9519
+ }
9520
+ function orgReadRolesWidens(declared, remote) {
9521
+ return writeRolesWidens(declared, remote);
9522
+ }
9058
9523
  function endUserAccessOf(v) {
9059
9524
  return v === "read" || v === "none" ? v : "readwrite";
9060
9525
  }
@@ -9151,6 +9616,9 @@ function remoteCollectionOf(c) {
9151
9616
  public: c.public === true,
9152
9617
  endUserAccess: endUserAccessOf(c.end_user_access),
9153
9618
  writeRoles: writeRolesOf(c.write_roles),
9619
+ orgField: typeof c.org_field === "string" && c.org_field.length > 0 ? c.org_field : null,
9620
+ orgReadRoles: writeRolesOf(c.org_read_roles),
9621
+ retainDays: typeof c.retain_days === "number" ? c.retain_days : null,
9154
9622
  actions: normalizeActions(c.actions),
9155
9623
  fields: Array.isArray(c.fields) ? c.fields : []
9156
9624
  };
@@ -9165,6 +9633,9 @@ function pulledCollectionBlock(c) {
9165
9633
  ...rc.public ? { public: true } : {},
9166
9634
  ...rc.endUserAccess !== "readwrite" ? { endUserAccess: rc.endUserAccess } : {},
9167
9635
  ...rc.writeRoles.length ? { writeRoles: rc.writeRoles } : {},
9636
+ ...rc.orgField ? { orgField: rc.orgField } : {},
9637
+ ...rc.orgReadRoles.length ? { orgReadRoles: rc.orgReadRoles } : {},
9638
+ ...rc.retainDays !== null ? { retainDays: rc.retainDays } : {},
9168
9639
  ...rc.actions.length ? { actions: rc.actions } : {},
9169
9640
  fields
9170
9641
  };
@@ -9229,6 +9700,12 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
9229
9700
  if (wr !== void 0 && (!Array.isArray(wr) || wr.some((r) => typeof r !== "string"))) {
9230
9701
  throw new Error(`cms.collections.${name}.writeRoles must be an array of role slugs (got ${JSON.stringify(wr)})`);
9231
9702
  }
9703
+ const wsErrs = workspaceCollectionErrors(name, def);
9704
+ if (wsErrs.length) throw new Error(wsErrs[0]);
9705
+ const rd = def.retainDays;
9706
+ if (!isDeclaredRetainDays(rd)) {
9707
+ throw new Error(`cms.collections.${name}.retainDays must be an integer 1..3650 (days) or null (got ${JSON.stringify(rd)})`);
9708
+ }
9232
9709
  }
9233
9710
  const read = await readRemote2(api);
9234
9711
  if (read.unavailable) {
@@ -9409,6 +9886,63 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
9409
9886
  });
9410
9887
  }
9411
9888
  }
9889
+ const declaredOrg = def.orgField ?? null;
9890
+ const declaredOrgRoles = writeRolesOf(def.orgReadRoles);
9891
+ const orgSteps = [];
9892
+ if (declaredOrg !== rc.orgField) {
9893
+ const widens = orgFieldWidens(declaredOrg, rc.orgField);
9894
+ if (!widens || allowDestructive) {
9895
+ orgSteps.push({
9896
+ collection: name,
9897
+ kind: "set-org-field",
9898
+ detail: `${name} orgField \u2192 ${declaredOrg === null ? "none" : `'${declaredOrg}'`}${rc.orgField ? ` (was '${rc.orgField}')` : ""}` + (widens ? " \u2014 WIDENS end-user access to workspace rows (full-config reconciliation)" : " (workspace scoping, narrows)")
9899
+ });
9900
+ } else {
9901
+ collChanges.push({
9902
+ collection: name,
9903
+ kind: "warn",
9904
+ detail: `${name}: orgField '${rc.orgField}' is set remotely but ${declaredOrg === null ? "absent" : `'${declaredOrg}'`} in config \u2014 that WIDENS which end users reach the rows, so it is NOT applied. Declare the remote orgField to keep it, or re-run with --allow-destructive.`
9905
+ });
9906
+ }
9907
+ }
9908
+ if (stableStringify(declaredOrgRoles) !== stableStringify(rc.orgReadRoles)) {
9909
+ const widens = orgReadRolesWidens(declaredOrgRoles, rc.orgReadRoles);
9910
+ const fmt = (r) => r.length ? `[${r.join(", ")}]` : "none (any member)";
9911
+ if (!widens || allowDestructive) {
9912
+ const step = {
9913
+ collection: name,
9914
+ kind: "set-org-read-roles",
9915
+ detail: `${name} orgReadRoles \u2192 ${fmt(declaredOrgRoles)} (was ${fmt(rc.orgReadRoles)})` + (widens ? " \u2014 WIDENS who may read (full-config reconciliation)" : " (narrows end-user reads)")
9916
+ };
9917
+ if (declaredOrg === null) orgSteps.unshift(step);
9918
+ else orgSteps.push(step);
9919
+ } else {
9920
+ collChanges.push({
9921
+ collection: name,
9922
+ kind: "warn",
9923
+ detail: `${name}: orgReadRoles are ${fmt(rc.orgReadRoles)} remotely but ${fmt(declaredOrgRoles)} in config \u2014 that WIDENS which workspace members may read, so it is NOT applied. Declare the remote roles to keep them, or re-run with --allow-destructive to widen.`
9924
+ });
9925
+ }
9926
+ }
9927
+ collChanges.push(...orgSteps);
9928
+ const declaredRetain = def.retainDays;
9929
+ if (declaredRetain !== void 0 && declaredRetain !== rc.retainDays) {
9930
+ const fmtDays = (d) => d === null ? "off (kept until deleted)" : `${d} days`;
9931
+ const shortens = retainDaysShortens(declaredRetain, rc.retainDays);
9932
+ if (!shortens || allowDestructive) {
9933
+ collChanges.push({
9934
+ collection: name,
9935
+ kind: "set-retain-days",
9936
+ detail: `${name} retainDays \u2192 ${fmtDays(declaredRetain)} (was ${fmtDays(rc.retainDays)})` + (shortens ? " \u2014 the nightly sweep soft-deletes items older than that" : "")
9937
+ });
9938
+ } else {
9939
+ collChanges.push({
9940
+ collection: name,
9941
+ kind: "warn",
9942
+ detail: `${name}: retainDays is ${fmtDays(rc.retainDays)} remotely but ${fmtDays(declaredRetain)} in config \u2014 that makes the nightly sweep soft-delete existing items, so it is NOT applied. Re-run with --allow-destructive to apply it.`
9943
+ });
9944
+ }
9945
+ }
9412
9946
  const declaredActions = normalizeActions(def.actions);
9413
9947
  if (stableStringify(declaredActions) !== stableStringify(rc.actions)) {
9414
9948
  collChanges.push({
@@ -9484,6 +10018,10 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
9484
10018
  if (access !== "readwrite") body.end_user_access = access;
9485
10019
  const roles = writeRolesOf(def.writeRoles);
9486
10020
  if (roles.length > 0) body.write_roles = roles;
10021
+ if (def.orgField) body.org_field = def.orgField;
10022
+ const orgRoles = writeRolesOf(def.orgReadRoles);
10023
+ if (orgRoles.length > 0) body.org_read_roles = orgRoles;
10024
+ if (typeof def.retainDays === "number") body.retain_days = def.retainDays;
9487
10025
  if (def.actions && def.actions.length > 0) body.actions = normalizeActions(def.actions);
9488
10026
  const res = await api("POST", "/v1/cms/collections", body);
9489
10027
  if (res.status !== 201 && res.status !== 200 && res.body.error?.code !== "collection_exists") {
@@ -9543,6 +10081,28 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
9543
10081
  throw new Error(`cms: set writeRoles on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
9544
10082
  }
9545
10083
  applied++;
10084
+ } else if (c.kind === "set-org-field" || c.kind === "set-org-read-roles") {
10085
+ const def = collections[c.collection];
10086
+ const remoteRc = remote.get(c.collection);
10087
+ const isField = c.kind === "set-org-field";
10088
+ const target = isField ? def.orgField ?? null : writeRolesOf(def.orgReadRoles);
10089
+ const widens = isField ? orgFieldWidens(target, remoteRc?.orgField ?? null) : orgReadRolesWidens(target, remoteRc?.orgReadRoles ?? []);
10090
+ const body = isField ? { org_field: target } : { org_read_roles: target };
10091
+ if (widens) body.allow_destructive = true;
10092
+ const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, body);
10093
+ if (res.status !== 200 && res.status !== 201) {
10094
+ const e = res.body.error ?? {};
10095
+ throw new Error(`cms: set ${isField ? "orgField" : "orgReadRoles"} on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
10096
+ }
10097
+ applied++;
10098
+ } else if (c.kind === "set-retain-days") {
10099
+ const target = collections[c.collection].retainDays ?? null;
10100
+ const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { retain_days: target });
10101
+ if (res.status !== 200 && res.status !== 201) {
10102
+ const e = res.body.error ?? {};
10103
+ throw new Error(`cms: set retainDays on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
10104
+ }
10105
+ applied++;
9546
10106
  } else if (c.kind === "set-actions") {
9547
10107
  const target = normalizeActions(collections[c.collection].actions);
9548
10108
  const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { actions: target });
@@ -9608,7 +10168,7 @@ function formatCmsChanges(changes) {
9608
10168
  if (c.kind === "destructive") return ` ! ${c.detail}`;
9609
10169
  if (c.kind === "warn") return ` \u26A0 ${c.detail}`;
9610
10170
  if (c.kind === "reindex") return ` \u21BB ${c.detail}`;
9611
- if (c.kind === "alter-flags" || c.kind === "set-owner-field" || c.kind === "set-end-user-access" || c.kind === "set-write-roles") return ` ~ ${c.detail}`;
10171
+ if (c.kind === "alter-flags" || c.kind === "set-owner-field" || c.kind === "set-end-user-access" || c.kind === "set-write-roles" || c.kind === "set-retain-days") return ` ~ ${c.detail}`;
9612
10172
  return ` + ${c.detail}`;
9613
10173
  }).join("\n");
9614
10174
  }
@@ -12503,7 +13063,11 @@ var TOOLS = [
12503
13063
  description: "Explicit locale wins; otherwise the recipient's stored `locale` attribute (users_upsert attributes.locale), then the tenant's config.defaultLocale."
12504
13064
  },
12505
13065
  send_at: { type: "string", description: "future ISO instant, \u2264 30 d out (mutually exclusive with delay_seconds)" },
12506
- delay_seconds: { type: "number", minimum: 0, maximum: 2592e3 }
13066
+ delay_seconds: { type: "number", minimum: 0, maximum: 2592e3 },
13067
+ to_email: { type: "string", description: "Deliver to this address instead of the user's stored email (an invitee with no account). user_id must still exist; suppressions are checked against this address." },
13068
+ from_name: { type: "string", maxLength: 64, description: "This send's sender name; default config fromName." },
13069
+ from_local_part: { type: "string", pattern: "^[a-z0-9._-]{1,40}$", description: "This send's from-address local part; the domain is always the config fromEmail domain." },
13070
+ reply_to: { type: "string", description: "This send's Reply-To address; default config replyTo." }
12507
13071
  },
12508
13072
  required: ["user_id", "template", "data"]
12509
13073
  },
@@ -12530,6 +13094,10 @@ var TOOLS = [
12530
13094
  channel: { type: "string", enum: ["email", "inbox", "both"] },
12531
13095
  send_at: { type: "string", description: "future ISO instant (\u2264 30 d)" },
12532
13096
  delay_seconds: { type: "number", minimum: 0, maximum: 2592e3 },
13097
+ to_email: { type: "string" },
13098
+ from_name: { type: "string", maxLength: 64 },
13099
+ from_local_part: { type: "string", pattern: "^[a-z0-9._-]{1,40}$" },
13100
+ reply_to: { type: "string" },
12533
13101
  idempotency_key: { type: "string" }
12534
13102
  },
12535
13103
  required: ["user_id", "template", "data"]
@@ -13181,7 +13749,7 @@ var TOOLS = [
13181
13749
  {
13182
13750
  name: "files_create_upload_url",
13183
13751
  feature: "files",
13184
- 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.",
13752
+ 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.",
13185
13753
  inputSchema: {
13186
13754
  type: "object",
13187
13755
  properties: {
@@ -13190,7 +13758,8 @@ var TOOLS = [
13190
13758
  content_type: { type: "string" },
13191
13759
  size_bytes: { type: "number" },
13192
13760
  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)." },
13193
- 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)." }
13761
+ 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)." },
13762
+ org_id: { type: "string", maxLength: 128, description: "The owning workspace (an orgs org_id). Omit for a personal file." }
13194
13763
  },
13195
13764
  required: ["user_id", "filename", "content_type", "size_bytes"]
13196
13765
  },
@@ -13212,14 +13781,19 @@ var TOOLS = [
13212
13781
  {
13213
13782
  name: "files_get_download_url",
13214
13783
  feature: "files",
13215
- description: "Mint a short-lived presigned GET URL for an available object.",
13784
+ description: 'Mint a short-lived presigned GET URL for an available object. disposition "inline" opens a PDF/image/ICO/audio/video in the browser (html, svg, js and other types always download); filename sets the saved name.',
13216
13785
  inputSchema: {
13217
13786
  type: "object",
13218
- properties: { object_id: { type: "string" } },
13787
+ properties: {
13788
+ object_id: { type: "string" },
13789
+ disposition: { type: "string", enum: ["inline", "attachment"], description: "Omit for the default (raster images inline, everything else attachment)." },
13790
+ filename: { type: "string", description: "Download filename (sanitised server-side, at most 200 characters). Default: the upload filename." }
13791
+ },
13219
13792
  required: ["object_id"]
13220
13793
  },
13221
13794
  method: "GET",
13222
- path: (a) => `/v1/files/${encodeURIComponent(String(a.object_id))}/download-url`
13795
+ path: (a) => `/v1/files/${encodeURIComponent(String(a.object_id))}/download-url`,
13796
+ queryArgs: ["disposition", "filename"]
13223
13797
  },
13224
13798
  {
13225
13799
  name: "files_get_download_urls",
@@ -13237,17 +13811,18 @@ var TOOLS = [
13237
13811
  {
13238
13812
  name: "files_list",
13239
13813
  feature: "files",
13240
- description: "List file objects (newest first; filter by user_id).",
13814
+ description: "List file objects (newest first; filter by user_id and/or org_id \u2014 a workspace's files).",
13241
13815
  inputSchema: {
13242
13816
  type: "object",
13243
13817
  properties: {
13244
13818
  user_id: { type: "string" },
13819
+ org_id: { type: "string", description: "Only this workspace's files (an orgs org_id)." },
13245
13820
  limit: { type: "number", default: 20 }
13246
13821
  }
13247
13822
  },
13248
13823
  method: "GET",
13249
13824
  path: "/v1/files",
13250
- queryArgs: ["user_id", "limit"]
13825
+ queryArgs: ["user_id", "org_id", "limit"]
13251
13826
  },
13252
13827
  {
13253
13828
  name: "files_delete",
@@ -13355,7 +13930,7 @@ var TOOLS = [
13355
13930
  {
13356
13931
  name: "cms_list_collections",
13357
13932
  feature: "cms",
13358
- description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against). Each field row carries its attributes \u2014 required, validation, index_slot, relation_to, computed/compute, is_unique, on_delete, read_roles (the end-user org roles allowed to READ that field; null = ungated), and on an equality-indexed field indexed: true + index_ready (equality filters on it are index-served once ready; until then, and on unindexed unslotted fields, they are a bounded scan \u2014 same results). A field with read_roles is omitted from end-user reads without a matching role and is never served on the public lane, so a missing key in an item's data may be a read gate rather than an unset value. Each collection row also carries end_user_access: 'readwrite' (default), 'read' (a verified end user \u2014 a thin-client key with a session \u2014 may read but every write answers 403 server_only) or 'none' (end-user reads and writes are 403 server_only), and write_roles: the roles a verified end user must hold to WRITE the collection (null = ungated; without one every end-user write answers 403 role_required, reads are unaffected); server keys are never affected. retain_days (null = keep) is the collection's retention: the nightly platform sweep soft-deletes live items created more than that many days ago.",
13933
+ description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against). Each field row carries its attributes \u2014 required, validation, index_slot, relation_to, computed/compute, is_unique, on_delete, read_roles (the end-user org roles allowed to READ that field; null = ungated), and on an equality-indexed field indexed: true + index_ready (equality filters on it are index-served once ready; until then, and on unindexed unslotted fields, they are a bounded scan \u2014 same results). A field with read_roles is omitted from end-user reads without a matching role and is never served on the public lane, so a missing key in an item's data may be a read gate rather than an unset value. Each collection row also carries end_user_access: 'readwrite' (default), 'read' (a verified end user \u2014 a thin-client key with a session \u2014 may read but every write answers 403 server_only) or 'none' (end-user reads and writes are 403 server_only), and write_roles: the roles a verified end user must hold to WRITE the collection (null = ungated; without one every end-user write answers 403 role_required, reads are unaffected); server keys are never affected. retain_days (null = keep) is the collection's retention: the nightly platform sweep soft-deletes live items created more than that many days ago. org_field (null = none) makes the collection workspace-scoped: a verified end user reads and writes only rows of the session's active org (no active org \u2192 403 org_required) and write_roles match the active-org role; org_read_roles (null = any member) gates reads by that role.",
13359
13934
  inputSchema: { type: "object", properties: {} },
13360
13935
  method: "GET",
13361
13936
  path: "/v1/cms/collections"
@@ -13703,15 +14278,18 @@ var TOOLS = [
13703
14278
  {
13704
14279
  name: "orgs_session_claims",
13705
14280
  feature: "orgs",
13706
- description: "The user's ACTIVE-ORG snapshot \u2014 the { org_id, role, perms[] } auth embeds as the session-JWT `org` claim when the tenant enables auth config orgClaims. Active org = the most recent membership (by joined_at); perms = the role's effective permission set (custom catalog entry wins, capped at 32). No membership \u2192 org_id null. EVENTUAL-CONSISTENT: JWTs recompute it only at mint/refresh \u2014 use orgs_check_permission for revocation-grade authz. Read-only \u2014 needs orgs:read.",
14281
+ description: "The org snapshot \u2014 { org_id, role, perms[] } \u2014 auth embeds as the session-JWT `org` claim when the tenant enables auth config orgClaims. Without org_id it resolves the user's MOST RECENT membership (by joined_at): the claim a session carries with plain orgClaims, and the one a first sign-in stores with orgClaims.activeOrg. With orgClaims.activeOrg a session then acts in the org it stored or switched to (POST /v1/auth/sessions/refresh with org_id), not necessarily the most recent one \u2014 pass org_id to read that org's claim. perms = the role's effective permission set (custom catalog entry wins, capped at 32). Not a member (or no membership) \u2192 org_id null. EVENTUAL-CONSISTENT: JWTs recompute it only at mint/refresh \u2014 use orgs_check_permission for revocation-grade authz. Read-only \u2014 needs orgs:read.",
13707
14282
  inputSchema: {
13708
14283
  type: "object",
13709
- properties: { user_id: { type: "string", description: "The end_user_id to resolve." } },
14284
+ properties: {
14285
+ user_id: { type: "string", description: "The end_user_id to resolve." },
14286
+ org_id: { type: "string", description: "Optional: resolve the claim for THIS org (the active org a session picked) instead of the most recent membership." }
14287
+ },
13710
14288
  required: ["user_id"]
13711
14289
  },
13712
14290
  method: "GET",
13713
14291
  path: "/v1/orgs/session-claims",
13714
- queryArgs: ["user_id"]
14292
+ queryArgs: ["user_id", "org_id"]
13715
14293
  },
13716
14294
  {
13717
14295
  name: "orgs_define_role",
@@ -13894,7 +14472,7 @@ var TOOLS = [
13894
14472
  {
13895
14473
  name: "functions_logs",
13896
14474
  feature: "functions",
13897
- description: "Log records of ONE deployed function, one per invocation (ts, outcome, wall_ms, cpu_ms, console lines, exceptions), oldest first, with `next_since` \u2014 pass it back as `since` to read only newer records (a tail). Without `since`: the most recent records of the last 24 hours. Kept 14 days. Needs functions:read.",
14475
+ description: 'Log records of ONE deployed function, one per invocation (ts, outcome, wall_ms, cpu_ms, console lines, exceptions), oldest first, with `next_since` \u2014 pass it back as `since` to read only newer records (a tail). Without `since`: the most recent records of the last 24 hours. Kept 14 days. Needs functions:read. To forward log lines to your own https endpoint (kept as long as you like), declare features.functions.logDrain { url, secretRef, minLevel } and push it, or PUT /v1/config/functions with { "logDrain": {\u2026} } (null removes it); this tool only reads.',
13898
14476
  inputSchema: {
13899
14477
  type: "object",
13900
14478
  properties: {
@@ -14065,7 +14643,7 @@ var TOOLS = [
14065
14643
  {
14066
14644
  name: "search_query",
14067
14645
  feature: "vector-search",
14068
- description: "Retrieve the top-k most relevant chunks from a vector-search collection. mode 'hybrid' (default) fuses semantic + keyword via RRF; 'vector' is pure semantic; 'keyword' is lexical. filter is a JSON object of equality constraints over chunk metadata. Returns ranked results with document/chunk attribution: score is the fused rank value (orders the hits, not a similarity); similarity is the cosine similarity to the query (-1..1, null in keyword mode) \u2014 threshold on similarity. embed_provider names the collection's embedder; 'mock' is the deterministic test embedder (ranks by a hash, not meaning), so tell the user search is not semantic until a real provider is set.",
14646
+ description: "Retrieve the top-k most relevant chunks from a vector-search collection. mode 'hybrid' (default) fuses semantic + keyword via RRF; 'vector' is pure semantic; 'keyword' is lexical. filter is a JSON object over top-level chunk metadata keys, ANDed: {key: value} is equality, {key: {$in: [a, b]}} matches any of 1\u201350 values (\u22644 $in keys); any other value is refused 422. Returns ranked results with document/chunk attribution: score is the fused rank value (orders the hits, not a similarity); similarity is the cosine similarity to the query (-1..1, null in keyword mode) \u2014 threshold on similarity. embed_provider names the collection's embedder; 'mock' is the deterministic test embedder (ranks by a hash, not meaning), so tell the user search is not semantic until a real provider is set.",
14069
14647
  inputSchema: {
14070
14648
  type: "object",
14071
14649
  properties: {
@@ -14073,8 +14651,9 @@ var TOOLS = [
14073
14651
  query: { type: "string", description: "Natural-language query text." },
14074
14652
  top_k: { type: "number", minimum: 1, maximum: 100, default: 10 },
14075
14653
  mode: { type: "string", enum: ["hybrid", "vector", "keyword"], default: "hybrid" },
14076
- filter: { type: "object", additionalProperties: true, description: 'Metadata equality filter, e.g. {"user_id":"u_42"}.' },
14077
- 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." }
14654
+ 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.' },
14655
+ 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." },
14656
+ 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." }
14078
14657
  },
14079
14658
  required: ["collection", "query"]
14080
14659
  },
@@ -14239,12 +14818,13 @@ var TOOLS = [
14239
14818
  template: { type: "string", description: "Override the answer template (defaults to rag config defaultTemplate)." },
14240
14819
  top_k: { type: "number", minimum: 1, maximum: 100, description: "Chunks to retrieve before budgeting." },
14241
14820
  mode: { type: "string", enum: ["hybrid", "vector", "keyword"] },
14242
- filter: { type: "object", additionalProperties: true, description: "Metadata equality filter applied to retrieval." },
14821
+ filter: { type: "object", additionalProperties: true, description: 'Metadata filter applied to retrieval: equality {"key":"value"} or any-of {"key":{"$in":[...]}} (1\u201350 values), keys ANDed.' },
14243
14822
  provider: { type: "string" },
14244
14823
  model: { type: "string" },
14245
14824
  input: { type: "object", additionalProperties: true, description: "Extra template vars beyond {{query}}/{{context}}." },
14246
14825
  user_id: { type: "string", description: "Opaque end_user_id for per-user usage metering." },
14247
- 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." }
14826
+ 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." },
14827
+ 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)." }
14248
14828
  },
14249
14829
  required: ["query"]
14250
14830
  },
@@ -14262,11 +14842,12 @@ var TOOLS = [
14262
14842
  collection: { type: "string", description: "Collection to search (defaults to rag config defaultCollection)." },
14263
14843
  top_k: { type: "number", minimum: 1, maximum: 100, description: "Chunks to retrieve." },
14264
14844
  mode: { type: "string", enum: ["hybrid", "vector", "keyword"] },
14265
- filter: { type: "object", additionalProperties: true, description: "Metadata equality filter applied to retrieval." },
14845
+ filter: { type: "object", additionalProperties: true, description: 'Metadata filter applied to retrieval: equality {"key":"value"} or any-of {"key":{"$in":[...]}} (1\u201350 values), keys ANDed.' },
14266
14846
  rerank: { type: "boolean", description: "Forward rerank:true to vector-search's BYO rerank pass (inert until configured there)." },
14267
14847
  boosts: { type: "object", additionalProperties: true, description: "Per-metadata-field boost specs: { field: { kind:'recency', halfLifeDays?, weight? } | { kind:'value', weights:{\u2026} } }." },
14268
14848
  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." },
14269
- 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." }
14849
+ 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." },
14850
+ 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)." }
14270
14851
  },
14271
14852
  required: ["query"]
14272
14853
  },
@@ -14276,34 +14857,36 @@ var TOOLS = [
14276
14857
  {
14277
14858
  name: "payments_check_entitlement",
14278
14859
  feature: "payments",
14279
- 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.",
14860
+ 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.",
14280
14861
  inputSchema: {
14281
14862
  type: "object",
14282
14863
  properties: {
14283
- user_id: { type: "string", description: "Opaque end_user_id (your user id)." },
14864
+ user_id: { type: "string", description: "Opaque end_user_id (your user id). Give this OR org_id." },
14865
+ 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." },
14284
14866
  entitlement: { type: "string", description: "Entitlement key to check, e.g. 'export' or 'seats'." }
14285
14867
  },
14286
- required: ["user_id", "entitlement"]
14868
+ required: ["entitlement"]
14287
14869
  },
14288
14870
  method: "GET",
14289
14871
  path: "/v1/payments/entitlements/check",
14290
- queryArgs: ["user_id", "entitlement"]
14872
+ queryArgs: ["user_id", "org_id", "entitlement"]
14291
14873
  },
14292
14874
  {
14293
14875
  name: "payments_balance",
14294
14876
  feature: "payments",
14295
- 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.",
14877
+ 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.",
14296
14878
  inputSchema: {
14297
14879
  type: "object",
14298
14880
  properties: {
14299
- user_id: { type: "string", description: "Opaque end_user_id (your user id)." },
14881
+ user_id: { type: "string", description: "Opaque end_user_id (your user id). Give this OR org_id." },
14882
+ 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." },
14300
14883
  credit_type: { type: "string", description: "Credit type to read, e.g. 'scans'." }
14301
14884
  },
14302
- required: ["user_id", "credit_type"]
14885
+ required: ["credit_type"]
14303
14886
  },
14304
14887
  method: "GET",
14305
14888
  path: "/v1/payments/credits/balance",
14306
- queryArgs: ["user_id", "credit_type"]
14889
+ queryArgs: ["user_id", "org_id", "credit_type"]
14307
14890
  },
14308
14891
  {
14309
14892
  name: "payments_consume",
@@ -14312,7 +14895,8 @@ var TOOLS = [
14312
14895
  inputSchema: {
14313
14896
  type: "object",
14314
14897
  properties: {
14315
- user_id: { type: "string", description: "Opaque end_user_id to debit." },
14898
+ user_id: { type: "string", description: "Opaque end_user_id to debit. Give this OR org_id." },
14899
+ 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." },
14316
14900
  credit_type: { type: "string", description: "Credit type to debit, e.g. 'scans'. Give this OR credit_types." },
14317
14901
  credit_types: {
14318
14902
  type: "array",
@@ -14327,7 +14911,7 @@ var TOOLS = [
14327
14911
  reason: { type: "string", description: "Optional human-readable reason recorded on the ledger row." },
14328
14912
  allow_split: { type: "boolean", description: "With credit_types only: when no single type covers amount, draw across them in order (default false: 402)." }
14329
14913
  },
14330
- required: ["user_id", "amount", "idempotency_key"]
14914
+ required: ["amount", "idempotency_key"]
14331
14915
  },
14332
14916
  method: "POST",
14333
14917
  path: "/v1/payments/credits/consume",
@@ -14848,6 +15432,10 @@ function collectSecretRefs(cfg) {
14848
15432
  if (m) add({ name: m[1], feature, via: `features.${feature}.${path}` });
14849
15433
  }
14850
15434
  }
15435
+ const drainRef = cfg.features?.functions?.logDrain?.secretRef;
15436
+ if (typeof drainRef === "string" && drainRef.replace(/^secret:/, "")) {
15437
+ add({ name: drainRef.replace(/^secret:/, ""), feature: "functions", via: "features.functions.logDrain.secretRef" });
15438
+ }
14851
15439
  for (const [fn, def] of Object.entries(cfg.functions ?? {})) {
14852
15440
  for (const ref of normalizeSecretRefs(def.secrets)) {
14853
15441
  const bare = ref.slice("secret:".length);
@@ -14923,7 +15511,8 @@ var COMPARED_LABELS = [
14923
15511
  ["subscriptions", "subscription"],
14924
15512
  ["campaigns", "campaign"],
14925
15513
  ["templates", "template"],
14926
- ["roles", "role"]
15514
+ ["roles", "role"],
15515
+ ["searchCollections", "search collection"]
14927
15516
  ];
14928
15517
  function formatCompared(c) {
14929
15518
  const parts = [];
@@ -16101,9 +16690,14 @@ var EXTRA_KNOWN_FLAGS = [
16101
16690
  "include-empty",
16102
16691
  "include-password-hashes",
16103
16692
  "with-password-hashes",
16693
+ "with-oauth-identities",
16104
16694
  "keep-source-id",
16695
+ "keep-timestamps",
16105
16696
  "oidc-issuer",
16106
16697
  "only",
16698
+ "files-owner",
16699
+ "files-org-from-prefix",
16700
+ "files-org-map",
16107
16701
  "pg-schema",
16108
16702
  "profile-table",
16109
16703
  "prune",
@@ -16804,7 +17398,8 @@ function inferMapping(source, opts = {}) {
16804
17398
  if (owner) mapping.userRefColumn = owner.column;
16805
17399
  for (const col of t.columns) {
16806
17400
  if (col.name === "created_at" || col.name === "updated_at") {
16807
- mapping.reasons.push(`R13: ${col.name} dropped \u2014 cms manages created_at/updated_at (original values not preserved)`);
17401
+ mapping.reasons.push(`R13: ${col.name} dropped \u2014 cms manages created_at/updated_at (original values kept with \`data --keep-timestamps\`)`);
17402
+ mapping.sourceTimestamps = { ...mapping.sourceTimestamps, [col.name === "created_at" ? "created" : "updated"]: col.name };
16808
17403
  continue;
16809
17404
  }
16810
17405
  const isOwnerCol = owner?.column === col.name;
@@ -17206,6 +17801,32 @@ function storagePatch(row2, fields, resolve8) {
17206
17801
  function readColumns(mapping, pk, extra = []) {
17207
17802
  return [.../* @__PURE__ */ new Set([pk, ...mapping.fields.map((f) => f.source), ...mapping.changeColumn ? [mapping.changeColumn] : [], ...extra])];
17208
17803
  }
17804
+ function sourceTimestampColumns(mapping) {
17805
+ if (mapping.sourceTimestamps) return { ...mapping.sourceTimestamps };
17806
+ const out = {};
17807
+ for (const r of mapping.reasons) {
17808
+ const m = /^R13: (created_at|updated_at) dropped/.exec(r);
17809
+ if (m?.[1] === "created_at") out.created = "created_at";
17810
+ if (m?.[1] === "updated_at") out.updated = "updated_at";
17811
+ }
17812
+ return out;
17813
+ }
17814
+ var TS_MIN_MS = 0;
17815
+ var TS_FUTURE_MARGIN_MS = 6e4;
17816
+ var tsIso = (raw, nowMs) => {
17817
+ let t;
17818
+ if (raw instanceof Date) t = raw.getTime();
17819
+ else if (typeof raw === "string" && raw !== "") t = Date.parse(raw);
17820
+ else return null;
17821
+ if (Number.isNaN(t) || t < TS_MIN_MS || t > nowMs + TS_FUTURE_MARGIN_MS) return null;
17822
+ return new Date(t).toISOString();
17823
+ };
17824
+ function sourceTimestampsOf(row2, cols, nowMs = Date.now()) {
17825
+ const created = cols.created ? tsIso(row2[cols.created], nowMs) : null;
17826
+ if (created === null) return void 0;
17827
+ const updated = cols.updated ? tsIso(row2[cols.updated], nowMs) : null;
17828
+ return updated !== null && updated >= created ? { created_at: created, updated_at: updated } : { created_at: created };
17829
+ }
17209
17830
  var SCALAR_UNIQUE = /* @__PURE__ */ new Set(["string", "int", "float", "datetime"]);
17210
17831
  function buildItem(row2, mapping, state, tableByCollection, resolve8) {
17211
17832
  const full = {};
@@ -17262,7 +17883,18 @@ function holdsContent(stored, item, storageTargets) {
17262
17883
  return cur === rowHash(v) || storageTargets.has(k) && cur === rowHash(item.hashInput[k]);
17263
17884
  });
17264
17885
  }
17265
- async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColumn, itemStatus = "published", delta }) {
17886
+ async function loadCmsTable({
17887
+ deps,
17888
+ plan,
17889
+ mapping,
17890
+ adapter,
17891
+ state,
17892
+ save,
17893
+ pkColumn,
17894
+ itemStatus = "published",
17895
+ delta,
17896
+ keepTimestamps = false
17897
+ }) {
17266
17898
  const table = mapping.table;
17267
17899
  const collection = mapping.collection;
17268
17900
  const key = `cms:${table}`;
@@ -17296,8 +17928,12 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
17296
17928
  let unhashedSame = 0;
17297
17929
  const enc2 = encodeURIComponent(collection);
17298
17930
  const start = cursors[key] ?? void 0;
17931
+ const tsCols = keepTimestamps ? sourceTimestampColumns(mapping) : {};
17932
+ if (keepTimestamps && tsCols.created === void 0) {
17933
+ deps.log(`cms ${table}: --keep-timestamps \u2014 the source has no created_at column; items take the time of the import`);
17934
+ }
17299
17935
  const readOpts = {
17300
- columns: readColumns(mapping, pk),
17936
+ columns: readColumns(mapping, pk, [tsCols.created, tsCols.updated].filter((c) => c !== void 0)),
17301
17937
  ...delta && mapping.changeColumn ? { changedSince: { column: mapping.changeColumn, since: delta.since } } : {}
17302
17938
  };
17303
17939
  const noteStorage = (sourceId, row2) => {
@@ -17380,7 +18016,12 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
17380
18016
  continue;
17381
18017
  }
17382
18018
  if (deps.dryRun) continue;
17383
- const res = await deps.api("POST", `/v1/cms/items/${enc2}`, { data: item.create, status: itemStatus });
18019
+ const srcTs = keepTimestamps ? sourceTimestampsOf(row2, tsCols) : void 0;
18020
+ const res = await deps.api("POST", `/v1/cms/items/${enc2}`, {
18021
+ data: item.create,
18022
+ status: itemStatus,
18023
+ ...srcTs ? { source_timestamps: srcTs } : {}
18024
+ });
17384
18025
  if (res.status === 201 || res.status === 200) {
17385
18026
  const itemId = res.body.data?.item_id;
17386
18027
  if (typeof itemId !== "string") {
@@ -17565,11 +18206,14 @@ async function loadUsers({ deps, adapter, mapping, state, save, delta = false })
17565
18206
  // src/migrate/loaders/credentials.ts
17566
18207
  var CREDENTIALS_BATCH_MAX = 500;
17567
18208
  var KEY = "users:credentials";
18209
+ var IDENTITIES_PER_USER_MAX = 8;
17568
18210
  function credentialEntry(row2, withHashes) {
18211
+ const identities = (row2.identities ?? []).slice(0, IDENTITIES_PER_USER_MAX);
17569
18212
  return {
17570
18213
  id: row2.id,
17571
18214
  ...withHashes && row2.password_hash ? { password_hash: row2.password_hash } : {},
17572
- ...row2.email_verified ? { email_verified: true } : {}
18215
+ ...row2.email_verified ? { email_verified: true } : {},
18216
+ ...identities.length > 0 ? { identities } : {}
17573
18217
  };
17574
18218
  }
17575
18219
  async function loadCredentials2({ deps, adapter, state, save, opts, delta = false }) {
@@ -17582,11 +18226,17 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
17582
18226
  const sent = hashesFor(state, KEY);
17583
18227
  if (cursors[KEY] === null) {
17584
18228
  deps.log(delta ? "credentials: incremental pass already complete for this --since window \u2014 skipping" : `credentials: already complete \u2014 skipping (\`data --since <time>\` re-reads it)`);
18229
+ if (opts.withIdentities) {
18230
+ deps.log(" \u26A0 --with-oauth-identities: social links attach only to users the import CREATES \u2014 users already imported by an earlier run are kept as they are and get no links. To carry the links, import into a fresh target (`vxil dev down` / a new project) with --with-password-hashes --with-oauth-identities.");
18231
+ }
17585
18232
  return result;
17586
18233
  }
17587
18234
  const reasons = /* @__PURE__ */ new Map();
17588
18235
  let withPassword = 0;
17589
- for await (const batch of adapter.readCredentials(cursors[KEY] ?? void 0)) {
18236
+ let withLinks = 0;
18237
+ let linked = 0;
18238
+ const readOpts = opts.withIdentities ? { identities: true } : void 0;
18239
+ for await (const batch of adapter.readCredentials(cursors[KEY] ?? void 0, readOpts)) {
17590
18240
  const entries = [];
17591
18241
  const digests = [];
17592
18242
  for (const row2 of batch.rows) {
@@ -17598,6 +18248,7 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
17598
18248
  continue;
17599
18249
  }
17600
18250
  if (entry.password_hash) withPassword++;
18251
+ if (entry.identities) withLinks++;
17601
18252
  entries.push(entry);
17602
18253
  digests.push(h);
17603
18254
  }
@@ -17615,6 +18266,7 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
17615
18266
  return result;
17616
18267
  }
17617
18268
  const data2 = res.body.data ?? {};
18269
+ linked += typeof data2.identities_linked === "number" ? data2.identities_linked : 0;
17618
18270
  const taken = (data2.results ?? []).filter((r) => r.status === "skipped" && r.reason === "id_taken").length;
17619
18271
  if (taken > 0) {
17620
18272
  result.errors.push(`credentials: ${taken} user id(s) already belong to another vxil project in this environment (user ids are unique across projects) \u2014 most often a rehearsal dev backend that imported the same users. Delete it (\`vxil dev down\`) or the project that holds them, then re-run: the cursor was NOT advanced and the import is idempotent. Left as is, those users would get a new id at sign-up and lose their data.`);
@@ -17636,10 +18288,13 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
17636
18288
  if (batch.nextCursor === null) break;
17637
18289
  }
17638
18290
  if (deps.dryRun) {
17639
- deps.log(`credentials: DRY-RUN \u2014 would import ${result.read - result.skipped} identit(ies)` + (opts.withHashes ? `, ${withPassword} with a password hash` : " (no hashes \u2014 --with-password-hashes not set)") + (opts.oidcIssuer ? ", each linked to the source issuer for the session hand-over" : "") + "; nothing written");
18291
+ deps.log(`credentials: DRY-RUN \u2014 would import ${result.read - result.skipped} identit(ies)` + (opts.withHashes ? `, ${withPassword} with a password hash` : " (no hashes \u2014 --with-password-hashes not set)") + (opts.oidcIssuer ? ", each linked to the source issuer for the session hand-over" : "") + (opts.withIdentities ? `, ${withLinks} with social sign-in links` : "") + "; nothing written");
17640
18292
  } else {
17641
18293
  cursors[KEY] = null;
17642
18294
  save();
18295
+ if (opts.withIdentities) {
18296
+ deps.log(`credentials: ${linked} social sign-in link(s) created for ${withLinks} user(s) \u2014 a link is made only for an identity this import creates or still holds, and a subject already linked stays put`);
18297
+ }
17643
18298
  }
17644
18299
  if (reasons.size > 0) {
17645
18300
  const list = [...reasons].map(([r, n]) => `${r}=${n}`).join(" ");
@@ -17798,10 +18453,19 @@ function objectOwner(path, filesOwner) {
17798
18453
  const seg = path.split("/").find((s) => s.length > 0);
17799
18454
  return seg;
17800
18455
  }
18456
+ function objectOrg(path, orgFromPrefix, orgMap) {
18457
+ if (!orgFromPrefix) return void 0;
18458
+ const source = path.split("/").find((s) => s.length > 0);
18459
+ const mapped = source !== void 0 ? orgMap?.[source] : void 0;
18460
+ if (typeof mapped !== "string" || mapped.length === 0) {
18461
+ 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)` };
18462
+ }
18463
+ return mapped;
18464
+ }
17801
18465
  var SHARED_LINK_ASK_MS = 100 * 365 * 24 * 3600 * 1e3;
17802
18466
  var SHARED_LINK_WARN_MS = 365 * 24 * 3600 * 1e3;
17803
18467
  var isUrl = (v) => typeof v === "string" && /^https?:\/\//i.test(v);
17804
- async function loadFiles({ deps, plan, adapter, state, save, filesOwner, fetchImpl, delta = false, fileLinks = "object_id" }) {
18468
+ async function loadFiles({ deps, plan, adapter, state, save, filesOwner, filesOrgFromPrefix = false, filesOrgMap, fetchImpl, delta = false, fileLinks = "object_id" }) {
17805
18469
  const results = [];
17806
18470
  if (plan.storageBuckets.length === 0) return results;
17807
18471
  if (!adapter.readObjects) {
@@ -17809,6 +18473,14 @@ async function loadFiles({ deps, plan, adapter, state, save, filesOwner, fetchIm
17809
18473
  return results;
17810
18474
  }
17811
18475
  const put = fetchImpl ?? fetch;
18476
+ if (filesOrgFromPrefix && !filesOwner) {
18477
+ 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");
18478
+ return results;
18479
+ }
18480
+ if (filesOrgFromPrefix && !filesOrgMap) {
18481
+ 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');
18482
+ return results;
18483
+ }
17812
18484
  for (const bucket of plan.storageBuckets) {
17813
18485
  const key = `files:${bucket.name}`;
17814
18486
  const map2 = state.idMap[key] ??= {};
@@ -17821,7 +18493,7 @@ async function loadFiles({ deps, plan, adapter, state, save, filesOwner, fetchIm
17821
18493
  continue;
17822
18494
  }
17823
18495
  if (deps.dryRun) continue;
17824
- const err = await uploadOne(deps, put, obj, map2, filesOwner);
18496
+ const err = await uploadOne(deps, put, obj, map2, filesOwner, filesOrgFromPrefix, filesOrgMap);
17825
18497
  if (err) {
17826
18498
  result.errors.push(err);
17827
18499
  result.skipped++;
@@ -17966,8 +18638,10 @@ async function loadFiles({ deps, plan, adapter, state, save, filesOwner, fetchIm
17966
18638
  }
17967
18639
  return results;
17968
18640
  }
17969
- async function uploadOne(deps, put, obj, map2, filesOwner) {
18641
+ async function uploadOne(deps, put, obj, map2, filesOwner, orgFromPrefix = false, orgMap) {
17970
18642
  const owner = objectOwner(obj.path, filesOwner);
18643
+ const orgId = objectOrg(obj.path, orgFromPrefix, orgMap);
18644
+ if (typeof orgId === "object") return orgId.error;
17971
18645
  if (!owner) return `files: object '${obj.path}' has no derivable owner \u2014 pass filesOwner (--files-owner)`;
17972
18646
  let bytes;
17973
18647
  try {
@@ -17978,12 +18652,14 @@ async function uploadOne(deps, put, obj, map2, filesOwner) {
17978
18652
  const contentType = obj.contentType ?? "application/octet-stream";
17979
18653
  const created = await deps.api("POST", "/v1/files/upload-url", {
17980
18654
  user_id: owner,
18655
+ ...orgId ? { org_id: orgId } : {},
17981
18656
  filename: obj.path.split("/").pop() ?? obj.path,
17982
18657
  content_type: contentType,
17983
18658
  size_bytes: bytes.length
17984
18659
  });
17985
18660
  if (created.status !== 201 && created.status !== 200) {
17986
- 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" : "";
18661
+ const code = created.body.error?.code;
18662
+ 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)" : "";
17987
18663
  return apiErrorLine(`files: upload-url for '${obj.path}' failed`, created) + hint;
17988
18664
  }
17989
18665
  const objectId = created.body.data?.object_id;
@@ -18716,6 +19392,7 @@ async function runData(deps, plan, adapter, opts = {}) {
18716
19392
  save: g.save,
18717
19393
  ...opts.pkColumns?.[table] !== void 0 ? { pkColumn: opts.pkColumns[table] } : {},
18718
19394
  ...opts.itemStatus !== void 0 ? { itemStatus: opts.itemStatus } : {},
19395
+ ...opts.keepTimestamps ? { keepTimestamps: true } : {},
18719
19396
  ...delta ? { delta } : {}
18720
19397
  });
18721
19398
  results.push(r);
@@ -18751,6 +19428,8 @@ async function runData(deps, plan, adapter, opts = {}) {
18751
19428
  save: g.save,
18752
19429
  delta: delta !== void 0,
18753
19430
  ...opts.filesOwner !== void 0 ? { filesOwner: opts.filesOwner } : {},
19431
+ ...opts.filesOrgFromPrefix ? { filesOrgFromPrefix: true } : {},
19432
+ ...opts.filesOrgMap ? { filesOrgMap: opts.filesOrgMap } : {},
18754
19433
  ...opts.fileLinks ? { fileLinks: opts.fileLinks } : {},
18755
19434
  ...putFetch !== void 0 ? { fetchImpl: putFetch } : {}
18756
19435
  }));
@@ -19069,7 +19748,14 @@ async function runMigrateData(io, opts = {}) {
19069
19748
  if (opts.oidcIssuer !== void 0 && !/^https:\/\/[^\s?#]+$/.test(opts.oidcIssuer)) {
19070
19749
  throw new Error(`--oidc-issuer '${opts.oidcIssuer}' must be an https URL with no query or fragment (Supabase: https://<ref>.supabase.co/auth/v1)`);
19071
19750
  }
19072
- const credentials = opts.withPasswordHashes || opts.oidcIssuer !== void 0 ? { withHashes: opts.withPasswordHashes === true, ...opts.oidcIssuer !== void 0 ? { oidcIssuer: opts.oidcIssuer } : {} } : void 0;
19751
+ if (opts.withOauthIdentities && !opts.withPasswordHashes) {
19752
+ throw new Error("--with-oauth-identities needs --with-password-hashes in the same run: the import creates each user once, so a user with both a password and a social link would otherwise lose the password for good");
19753
+ }
19754
+ const credentials = opts.withPasswordHashes || opts.oidcIssuer !== void 0 || opts.withOauthIdentities ? {
19755
+ withHashes: opts.withPasswordHashes === true,
19756
+ ...opts.oidcIssuer !== void 0 ? { oidcIssuer: opts.oidcIssuer } : {},
19757
+ ...opts.withOauthIdentities ? { withIdentities: true } : {}
19758
+ } : void 0;
19073
19759
  const tableNames = new Set(plan.tables.map((t) => t.table));
19074
19760
  for (const o of opts.only ?? []) {
19075
19761
  if (!DATA_STEPS.has(o) && !tableNames.has(o)) {
@@ -19104,7 +19790,11 @@ async function runMigrateData(io, opts = {}) {
19104
19790
  ...opts.prune ? { prune: true } : {},
19105
19791
  ...specs ? { payments: specs } : {},
19106
19792
  ...credentials ? { credentials } : {},
19107
- ...opts.fileLinks ? { fileLinks: opts.fileLinks } : {}
19793
+ ...opts.fileLinks ? { fileLinks: opts.fileLinks } : {},
19794
+ ...opts.filesOwner !== void 0 ? { filesOwner: opts.filesOwner } : {},
19795
+ ...opts.filesOrgFromPrefix ? { filesOrgFromPrefix: true } : {},
19796
+ ...opts.filesOrgMap ? { filesOrgMap: opts.filesOrgMap } : {},
19797
+ ...opts.keepTimestamps ? { keepTimestamps: true } : {}
19108
19798
  });
19109
19799
  io.log(`${execute ? "\nmigrate data" : "\nmigrate data (DRY-RUN \u2014 zero writes)"}${since ? ` \u2014 incremental since ${since}${opts.prune ? " + prune" : ""}` : ""}:`);
19110
19800
  for (const r of results) io.log(formatLoadResult(r));
@@ -19868,6 +20558,28 @@ export default defineConfig({
19868
20558
  "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"
19869
20559
  }
19870
20560
  },
20561
+ {
20562
+ "id": "ai-knowledge-base",
20563
+ "title": "AI Knowledge Base (per-workspace search \xB7 grounded answers)",
20564
+ "vertical": "ai",
20565
+ "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.",
20566
+ "collections": [
20567
+ "articles"
20568
+ ],
20569
+ "features": [
20570
+ "auth",
20571
+ "orgs",
20572
+ "cms",
20573
+ "vector-search",
20574
+ "ai",
20575
+ "rag"
20576
+ ],
20577
+ "hasFunctions": false,
20578
+ "byoKeys": [],
20579
+ "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",
20580
+ "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",
20581
+ "functions": {}
20582
+ },
19871
20583
  {
19872
20584
  "id": "fal-media",
19873
20585
  "title": "AI Media Studio (fal.ai images \xB7 Veo video \xB7 credits)",
@@ -20098,6 +20810,31 @@ export default defineConfig({
20098
20810
  "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"
20099
20811
  }
20100
20812
  },
20813
+ {
20814
+ "id": "client-portal",
20815
+ "title": "Client Portal (one workspace per client)",
20816
+ "vertical": "support",
20817
+ "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.",
20818
+ "collections": [
20819
+ "requests"
20820
+ ],
20821
+ "features": [
20822
+ "auth",
20823
+ "orgs",
20824
+ "cms",
20825
+ "realtime",
20826
+ "files",
20827
+ "notifications",
20828
+ "functions"
20829
+ ],
20830
+ "hasFunctions": true,
20831
+ "byoKeys": [],
20832
+ "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",
20833
+ "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",
20834
+ "functions": {
20835
+ "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"
20836
+ }
20837
+ },
20101
20838
  {
20102
20839
  "id": "crm",
20103
20840
  "title": "Sales CRM",
@@ -20139,11 +20876,38 @@ export default defineConfig({
20139
20876
  "oidc_client_secret"
20140
20877
  ],
20141
20878
  "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",
20142
- "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',
20879
+ "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',
20143
20880
  "functions": {
20144
20881
  "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"
20145
20882
  }
20146
20883
  },
20884
+ {
20885
+ "id": "team-saas",
20886
+ "title": "Team SaaS (workspaces \xB7 roles \xB7 workspace billing)",
20887
+ "vertical": "saas",
20888
+ "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.",
20889
+ "collections": [
20890
+ "folders",
20891
+ "docs"
20892
+ ],
20893
+ "features": [
20894
+ "auth",
20895
+ "orgs",
20896
+ "cms",
20897
+ "realtime",
20898
+ "files",
20899
+ "payments",
20900
+ "functions"
20901
+ ],
20902
+ "hasFunctions": true,
20903
+ "byoKeys": [],
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 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",
20905
+ "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",
20906
+ "functions": {
20907
+ "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",
20908
+ "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"
20909
+ }
20910
+ },
20147
20911
  {
20148
20912
  "id": "admin-console",
20149
20913
  "title": "Admin console (accounts \xB7 approvals \xB7 audit)",
@@ -20291,8 +21055,8 @@ export default defineConfig({
20291
21055
  "byoKeys": [
20292
21056
  "slack_webhook_url"
20293
21057
  ],
20294
- "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",
20295
- "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",
21058
+ "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",
21059
+ "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",
20296
21060
  "functions": {
20297
21061
  "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"
20298
21062
  }
@@ -20388,7 +21152,7 @@ export default defineConfig({
20388
21152
  "oidc_client_secret"
20389
21153
  ],
20390
21154
  "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",
20391
- "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',
21155
+ "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',
20392
21156
  "functions": {
20393
21157
  "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",
20394
21158
  "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",
@@ -20413,8 +21177,8 @@ export default defineConfig({
20413
21177
  ],
20414
21178
  "hasFunctions": true,
20415
21179
  "byoKeys": [],
20416
- "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",
20417
- "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',
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// \"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",
21181
+ "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',
20418
21182
  "functions": {
20419
21183
  "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",
20420
21184
  "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"
@@ -20467,7 +21231,7 @@ export default defineConfig({
20467
21231
  "hasFunctions": true,
20468
21232
  "byoKeys": [],
20469
21233
  "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",
20470
- "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',
21234
+ "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',
20471
21235
  "functions": {
20472
21236
  "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",
20473
21237
  "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",
@@ -21046,6 +21810,9 @@ function quoteIdent2(name) {
21046
21810
  if (!IDENT_RE2.test(name)) throw new Error(`unsafe identifier '${name}' \u2014 only [A-Za-z_][A-Za-z0-9_]* is readable`);
21047
21811
  return `"${name}"`;
21048
21812
  }
21813
+ function parseTimestampNoTz(text) {
21814
+ return /* @__PURE__ */ new Date(`${text.replace(" ", "T")}Z`);
21815
+ }
21049
21816
  function redactSourceUrl(url) {
21050
21817
  try {
21051
21818
  const u = new URL(url);
@@ -21298,6 +22065,10 @@ var GenericPgAdapter = class {
21298
22065
  // transaction-pooler (pgbouncer/supavisor) compatible
21299
22066
  onnotice: () => {
21300
22067
  },
22068
+ // timestamp-without-tz read as UTC (the session only ever reads).
22069
+ types: {
22070
+ timestampNoTz: { to: 1114, from: [1114], parse: parseTimestampNoTz, serialize: (d) => d.toISOString() }
22071
+ },
21301
22072
  connection: {
21302
22073
  application_name: "vxil-migrate",
21303
22074
  // startup-parameter copies of the session SETs — they survive driver
@@ -21614,6 +22385,25 @@ var supabaseSql = {
21614
22385
  params: after !== null ? [after] : []
21615
22386
  };
21616
22387
  },
22388
+ /** auth.identities column NAMES (GoTrue added `provider_id` in 2023; older
22389
+ * projects kept the provider subject in `id`). */
22390
+ identitiesColumns: () => ({
22391
+ text: `
22392
+ SELECT a.attname AS column_name
22393
+ FROM pg_attribute a
22394
+ JOIN pg_class c ON c.oid = a.attrelid
22395
+ JOIN pg_namespace n ON n.oid = c.relnamespace
22396
+ WHERE n.nspname = 'auth' AND c.relname = 'identities'
22397
+ AND a.attnum > 0 AND NOT a.attisdropped`,
22398
+ params: []
22399
+ }),
22400
+ /** MIGRATE-OAUTH-IDENTITIES: the social links of ONE credentials page —
22401
+ * provider + the provider's subject only (never identity_data, never a
22402
+ * token), the four providers vxil links by subject. */
22403
+ identitiesFor: (userIds, subjectColumn) => ({
22404
+ text: `SELECT user_id::text AS user_id, provider, ${subjectColumn}::text AS subject FROM auth.identities WHERE user_id = ANY($1::uuid[]) AND provider IN ('google', 'apple', 'github', 'facebook') ORDER BY user_id, provider, ${subjectColumn}`,
22405
+ params: [userIds]
22406
+ }),
21617
22407
  /** does this database carry the Storage schema? */
21618
22408
  storageProbe: () => ({
21619
22409
  text: `SELECT to_regclass('storage.buckets') IS NOT NULL AS present`,
@@ -21706,17 +22496,36 @@ var SupabaseAdapter = class extends GenericPgAdapter {
21706
22496
  }
21707
22497
  /** Credential pages (supabaseSql.credentialsPage). The rows hold bcrypt
21708
22498
  * hashes: never logged, never written anywhere but the import request. */
21709
- async *readCredentials(cursor) {
22499
+ async *readCredentials(cursor, opts = {}) {
21710
22500
  const source = await this.introspect();
21711
22501
  if (!source.authUsers?.present) throw new Error("auth.users not present on this source");
21712
22502
  const cols = source.authUsers.columns ?? [];
21713
22503
  if (!cols.includes("encrypted_password")) throw new Error("auth.users has no encrypted_password column \u2014 nothing to import");
22504
+ let subjectColumn = null;
22505
+ if (opts.identities) {
22506
+ const icols = (await this.q(supabaseSql.identitiesColumns())).map((r) => r.column_name);
22507
+ if (!icols.includes("user_id") || !icols.includes("provider")) {
22508
+ throw new Error("auth.identities not found on this source \u2014 drop --with-oauth-identities");
22509
+ }
22510
+ subjectColumn = icols.includes("provider_id") ? "provider_id" : "id";
22511
+ }
21714
22512
  let after = cursor !== void 0 && cursor !== "" ? cursor : null;
21715
22513
  for (; ; ) {
21716
22514
  const rows = await this.q(supabaseSql.credentialsPage(after, this.batchSize, cols.includes("deleted_at")));
22515
+ const links = /* @__PURE__ */ new Map();
22516
+ if (subjectColumn !== null && rows.length > 0) {
22517
+ for (const r of await this.q(supabaseSql.identitiesFor(rows.map((x) => String(x.id)), subjectColumn))) {
22518
+ if (typeof r.subject !== "string" || r.subject === "") continue;
22519
+ const list = links.get(String(r.user_id)) ?? [];
22520
+ list.push({ provider: String(r.provider), subject: r.subject });
22521
+ links.set(String(r.user_id), list);
22522
+ }
22523
+ }
21717
22524
  const out = rows.map((r) => {
21718
22525
  const h = typeof r.encrypted_password === "string" && r.encrypted_password.length > 0 ? r.encrypted_password : null;
21719
- return { id: String(r.id), password_hash: h, email_verified: r.email_verified === true };
22526
+ const row2 = { id: String(r.id), password_hash: h, email_verified: r.email_verified === true };
22527
+ if (subjectColumn !== null) row2.identities = links.get(String(r.id)) ?? [];
22528
+ return row2;
21720
22529
  });
21721
22530
  const next = rows.length === this.batchSize ? String(rows[rows.length - 1].id) : null;
21722
22531
  yield { rows: out, nextCursor: next };
@@ -22428,6 +23237,11 @@ async function runApply(apply, sel = "prod", opts = {}) {
22428
23237
  report.errors++;
22429
23238
  }
22430
23239
  };
23240
+ {
23241
+ const drain = cfg.features?.functions?.logDrain;
23242
+ const drainErr = drain === void 0 ? null : logDrainShapeError(drain);
23243
+ if (drainErr) throw new Error(`features.functions.logDrain: ${drainErr} \u2014 nothing was ${apply ? "pushed" : "planned"}`);
23244
+ }
22431
23245
  let cmsApi = api;
22432
23246
  if (apply) {
22433
23247
  const enabledAt = await enableCmsBeforeSchema({ api, cfg });
@@ -22459,7 +23273,6 @@ cms: enabled (v${enabledAt}) before its schema \u2014 the declared cms config fo
22459
23273
  compared.collections += Object.keys(cfg.cms.collections).length;
22460
23274
  });
22461
23275
  const { functions: _fnFeature, ...configFeatures } = cfg.features;
22462
- void _fnFeature;
22463
23276
  const serverApiState = {};
22464
23277
  await runSection("config", async () => {
22465
23278
  if (!Object.keys(configFeatures).length) return;
@@ -22476,6 +23289,8 @@ ${r.feature}:`);
22476
23289
  }
22477
23290
  console.log(`
22478
23291
  ${r.feature} (remote v${r.version}):`);
23292
+ const activeOrgWarn = activeOrgEnableWarning(r.feature, r.changes);
23293
+ if (activeOrgWarn) console.error(`vxil: warning \u2014 ${activeOrgWarn}`);
22479
23294
  const { kept, ignored } = partitionConfigChanges(r.feature, r.changes, ignore);
22480
23295
  console.log(formatDiff(kept));
22481
23296
  if (ignored.length) console.log(` (${ignored.length} ignored: ${ignored.map((c) => c.path).join(", ")})`);
@@ -22488,6 +23303,30 @@ ${r.feature} (remote v${r.version}):`);
22488
23303
  compared.features++;
22489
23304
  }
22490
23305
  });
23306
+ await runSection("log-drain", async () => {
23307
+ const r = await planOrPushLogDrain({ api, fnFeature: _fnFeature, apply });
23308
+ if (!r) return;
23309
+ if (r.remoteUnavailable) {
23310
+ console.log("\nfunctions.logDrain:");
23311
+ notCompared(r.remoteUnavailable, "needs features:read or admin");
23312
+ return;
23313
+ }
23314
+ const show = (d) => {
23315
+ const url = d?.url;
23316
+ if (typeof url !== "string") return "(none)";
23317
+ try {
23318
+ return new URL(url).host;
23319
+ } catch {
23320
+ return "(invalid url)";
23321
+ }
23322
+ };
23323
+ console.log(`
23324
+ functions.logDrain (remote v${r.version}):`);
23325
+ console.log(r.changed ? ` ~ logDrain: ${show(r.from)} \u2192 ${show(r.to)}` : " (no changes)");
23326
+ if (r.applied) console.log(` \u2713 applied as v${r.applied}`);
23327
+ if (r.changed) count("config:functions.logDrain");
23328
+ compared.features++;
23329
+ });
22491
23330
  const strictApiState = hasFlag("strict-api-state");
22492
23331
  const declaredApiStateKeys = [];
22493
23332
  await runSection("api-state", async () => {
@@ -22536,7 +23375,8 @@ ${r.feature}.${r.datum} (declared in config):`);
22536
23375
  if (!apply) count(key);
22537
23376
  }
22538
23377
  const n = r.summary.created.length + r.summary.updated.length + r.summary.recreated.length + r.summary.unchanged.length + r.summary.undeclared.length + r.summary.deleted.length + r.summary.pending_destructive.length + r.summary.ambiguous.length;
22539
- compared[r.datum] = (compared[r.datum] ?? 0) + n;
23378
+ const ck = r.datum === "collections" ? "searchCollections" : r.datum;
23379
+ compared[ck] = (compared[ck] ?? 0) + n;
22540
23380
  }
22541
23381
  });
22542
23382
  await runSection("functions", async () => {
@@ -24350,6 +25190,16 @@ ${plan.rationale}`);
24350
25190
  if (pulled.note) pullNotes.push(pulled.note);
24351
25191
  }
24352
25192
  }
25193
+ if (features.includes("vector-search")) {
25194
+ const r = await api("GET", "/v1/search/collections");
25195
+ if (r.status === 200 && featBlocks["vector-search"]) {
25196
+ const block = featBlocks["vector-search"];
25197
+ const pulled = pulledSearchCollections(r.body.data?.collections ?? []);
25198
+ if (pulled.collections) block.collections = pulled.collections;
25199
+ else delete block.collections;
25200
+ if (pulled.note) pullNotes.push(pulled.note);
25201
+ }
25202
+ }
24353
25203
  if (features.includes("ai")) {
24354
25204
  const block = featBlocks.ai;
24355
25205
  delete block.templates;
@@ -24901,7 +25751,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
24901
25751
  architect "<describe your app>" \u2014 plain English in, a reviewed vxil.config.ts draft out
24902
25752
  plan [--explain] \xB7 push [--dev|--target <name>|--prod] [--server [--resume <apply_id>]] [--no-gen] [--skip-functions] \xB7 pull \xB7 gen [--check] [--offline] [--out <f>] [--no-mcp] [--mcp-out <f>]
24903
25753
  push production overrides: --allow-mock-in-prod \xB7 --allow-sandbox-in-prod \xB7 --allow-dev-origins-in-prod \xB7 --yes
24904
- diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--strict-api-state] [--api-state] \u2014 CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error (an unreadable remote is 2; secrets by name only, unstored refs informational unless --strict-secrets; declared policies/subscriptions/templates/roles compared to live, an undeclared live row informational unless --strict-api-state)
25754
+ diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--strict-api-state] [--api-state] \u2014 CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error (an unreadable remote is 2; secrets by name only, unstored refs informational unless --strict-secrets; declared policies/subscriptions/templates/roles/search collections compared to live, an undeclared live row informational unless --strict-api-state)
24905
25755
  doctor [--dev|--target <name>] \u2014 + secrets referenced-vs-stored (advisory) + per-function served/published/local hash + keys with last-used (stored session)
24906
25756
  versions <feature> \xB7 rollback <feature> --to <v>
24907
25757
  config draft show|save [--file <f>]|publish|discard <feature> \xB7 config draft duplicate <feature> --from <v> \u2014 stage a config change without making it live
@@ -25773,6 +26623,13 @@ function migrateUsage() {
25773
26623
  rehashed at each user's first sign-in) \xB7 [--oidc-issuer https://<ref>.supabase.co/auth/v1]
25774
26624
  link users for the installed-app session hand-over \xB7 [--file-links shared-link] stored
25775
26625
  public file URLs become stable shared links instead of object ids
26626
+ \xB7 [--with-oauth-identities] carry the Google/Apple/GitHub/Facebook sign-in links (needs
26627
+ --with-password-hashes in the same run) \xB7 [--keep-timestamps] keep each row's source
26628
+ created_at / updated_at (cms creates; server key only)
26629
+ \xB7 [--files-owner <user id>] the owner of every migrated file (default: the path's first
26630
+ segment) \xB7 [--files-org-from-prefix] the path's first segment is the WORKSPACE (org_id):
26631
+ workspace files, kept when the uploader is erased (needs --files-owner and
26632
+ --files-org-map <file.json>: { "<source workspace id>": "<vxil org id>" })
25776
26633
  verify reconcile source vs target counts + re-links + restate the residuals (exit 1 on deltas)
25777
26634
  payments --from-provider stripe|paddle|paypal|revenuecat (--customers <file.csv> | --all) [--dry-run]
25778
26635
  backfill SUBSCRIPTIONS through the server sync leg (the project's own provider keys; resumable via
@@ -25872,12 +26729,38 @@ ${migrateUsage()}`);
25872
26729
  fail(`--file-links must be shared-link or object_id (got '${fileLinks}')`);
25873
26730
  }
25874
26731
  const oidcIssuer = flag("oidc-issuer");
26732
+ const filesOwner = flag("files-owner");
26733
+ if (hasFlag("files-org-from-prefix") && !filesOwner) {
26734
+ fail("--files-org-from-prefix needs --files-owner <user id>: the path prefix is the workspace, not the owner");
26735
+ }
26736
+ const orgMapPath = flag("files-org-map");
26737
+ let filesOrgMap;
26738
+ if (hasFlag("files-org-from-prefix") && orgMapPath === void 0) {
26739
+ 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)');
26740
+ }
26741
+ if (orgMapPath !== void 0) {
26742
+ let parsed;
26743
+ try {
26744
+ parsed = JSON.parse(readFileSync8(resolve7(orgMapPath), "utf8"));
26745
+ } catch (e) {
26746
+ fail(`--files-org-map: cannot read ${orgMapPath} as JSON \u2014 ${e.message}`);
26747
+ }
26748
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.values(parsed).some((v) => typeof v !== "string" || v.length < 1 || v.length > 128)) {
26749
+ fail('--files-org-map must be a JSON object of "<source workspace id>": "<vxil org id>" strings');
26750
+ }
26751
+ filesOrgMap = parsed;
26752
+ }
25875
26753
  const results = await runMigrateData({ api, adapter, cwd, log }, {
25876
26754
  execute: hasFlag("execute"),
25877
26755
  resume: hasFlag("resume"),
25878
26756
  ...hasFlag("with-password-hashes") ? { withPasswordHashes: true } : {},
25879
26757
  ...oidcIssuer !== void 0 ? { oidcIssuer } : {},
26758
+ ...hasFlag("with-oauth-identities") ? { withOauthIdentities: true } : {},
26759
+ ...hasFlag("keep-timestamps") ? { keepTimestamps: true } : {},
25880
26760
  ...fileLinks !== void 0 ? { fileLinks } : {},
26761
+ ...filesOwner !== void 0 ? { filesOwner } : {},
26762
+ ...hasFlag("files-org-from-prefix") ? { filesOrgFromPrefix: true } : {},
26763
+ ...filesOrgMap ? { filesOrgMap } : {},
25881
26764
  ...only?.length ? { only } : {},
25882
26765
  ...hasFlag("assume-schema") ? { assumeSchema: true } : {},
25883
26766
  ...since !== void 0 ? { since } : {},