@vxil/cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/vxil.js CHANGED
@@ -8,7 +8,7 @@ var __export = (target, all) => {
8
8
  // bin/vxil.ts
9
9
  import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync5, existsSync as existsSync8, appendFileSync as appendFileSync2, readFileSync as readFileSync8, chmodSync as chmodSync2, openSync, closeSync, fstatSync, renameSync } from "node:fs";
10
10
  import { spawnSync } from "node:child_process";
11
- import { resolve as resolve7, dirname as dirname5 } from "node:path";
11
+ import { resolve as resolve7, dirname as dirname5, basename as basename4 } from "node:path";
12
12
  import { createInterface } from "node:readline";
13
13
  import { watch } from "node:fs";
14
14
  import { createServer } from "node:http";
@@ -2767,7 +2767,7 @@ function sameStringSet(a, b2) {
2767
2767
  return A.size === B.size && [...A].every((x) => B.has(x));
2768
2768
  }
2769
2769
  function planWebhookSubscriptions(declared, live) {
2770
- const plan = { create: [], recreate: [], unchanged: [], undeclared: [] };
2770
+ const plan = { create: [], update: [], unchanged: [], undeclared: [] };
2771
2771
  const declaredUrls = new Set(declared.map((d) => d.target_url));
2772
2772
  const matched = /* @__PURE__ */ new Set();
2773
2773
  for (const d of declared) {
@@ -2781,7 +2781,7 @@ function planWebhookSubscriptions(declared, live) {
2781
2781
  const chosen = exact ?? rows[0];
2782
2782
  matched.add(chosen.sub_id);
2783
2783
  if (exact) plan.unchanged.push(d.target_url);
2784
- else plan.recreate.push({ sub_id: chosen.sub_id, target_url: d.target_url, declared: d });
2784
+ else plan.update.push({ sub_id: chosen.sub_id, target_url: d.target_url, event_prefixes: [...want] });
2785
2785
  }
2786
2786
  for (const l of live) {
2787
2787
  if (matched.has(l.sub_id)) continue;
@@ -2876,9 +2876,11 @@ function canonicalJson(v) {
2876
2876
  if (!format_exports.Has("email")) {
2877
2877
  format_exports.Set("email", (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v));
2878
2878
  }
2879
+ var FN_RETRY_MAX_ATTEMPTS = 5;
2879
2880
  var NOTIF_OVERRIDE_SUBJECT_MAX = 500;
2880
2881
  var NOTIF_OVERRIDE_BODY_MAX = 2e4;
2881
2882
  var NOTIF_OVERRIDE_MAX_BYTES = 64 * 1024;
2883
+ var SNS_TOPIC_ARN_PATTERN = "^arn:aws(-[a-z]+)?:sns:[a-z0-9-]+:\\d{12}:[A-Za-z0-9_-]{1,256}$";
2882
2884
  var SES_REGION_PATTERN = "^[a-z]{2}(-[a-z]+)+-\\d$";
2883
2885
  var NotificationsConfigSchema = Type.Object({
2884
2886
  enabled: Type.Boolean({ default: true }),
@@ -2915,7 +2917,14 @@ var NotificationsConfigSchema = Type.Object({
2915
2917
  ses: Type.Optional(Type.Object({
2916
2918
  region: Type.String({ pattern: SES_REGION_PATTERN, maxLength: 32 }),
2917
2919
  accessKeyIdRef: Type.String({ minLength: 1 }),
2918
- secretAccessKeyRef: Type.String({ minLength: 1 })
2920
+ secretAccessKeyRef: Type.String({ minLength: 1 }),
2921
+ // Bounce/complaint intake (2026-09-25): the SNS topic SES publishes its
2922
+ // bounce/complaint events to; its HTTPS subscription posts to the worker's
2923
+ // `/v1/notifications/webhooks/ses/<tenant>/<tag>` route, which accepts a
2924
+ // message ONLY when its TopicArn equals this value (fail closed: unset ⇒
2925
+ // the lane answers 404). Plain config, not a secret — the AWS signature is
2926
+ // the proof. Inside the Optional bag ⇒ zero leaf cost.
2927
+ snsTopicArn: Type.Optional(Type.String({ pattern: SNS_TOPIC_ARN_PATTERN, maxLength: 320 }))
2919
2928
  })),
2920
2929
  defaultLocale: Type.String({ default: "en-US" }),
2921
2930
  // nested objects carry `default: {}` so Value.Default can materialize them
@@ -3012,6 +3021,28 @@ var NotificationsConfigSchema = Type.Object({
3012
3021
  freqCapPerUserPerDay: Type.Integer({ default: 5, minimum: 0 })
3013
3022
  }))
3014
3023
  });
3024
+ var GENERATION_DEFAULTS = {
3025
+ /** per-tenant in-flight generation cap (separate budget from queue jobs) */
3026
+ maxConcurrent: 20,
3027
+ /** default expiry/timeout when the descriptor omits one — 5 min */
3028
+ defaultTimeoutMs: 5 * 6e4,
3029
+ /** hard ceiling a tenant-supplied timeout is clamped to — 1 h */
3030
+ maxTimeoutMs: 60 * 6e4,
3031
+ /** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
3032
+ pollMaxAttempts: 60,
3033
+ /** MANDATORY per-hold cap on a `reserve_credits.amount` (clamped, never rejected) */
3034
+ maxReserveCredits: 1e3,
3035
+ /** MANDATORY per-tenant ceiling on the sum of un-settled provisional holds */
3036
+ maxOutstandingReserveCredits: 1e5
3037
+ };
3038
+ var GENERATION_BOUNDS = {
3039
+ maxConcurrent: { min: 1, max: 200 },
3040
+ defaultTimeoutMs: { min: 1e3, max: 36e5 },
3041
+ maxTimeoutMs: { min: 1e3, max: 36e5 },
3042
+ pollMaxAttempts: { min: 1, max: 1e3 },
3043
+ maxReserveCredits: { min: 1, max: 1e6 },
3044
+ maxOutstandingReserveCredits: { min: 1, max: 1e8 }
3045
+ };
3015
3046
  var JobsConfigSchema = Type.Object({
3016
3047
  enabled: Type.Boolean({ default: true }),
3017
3048
  retry: Type.Object(
@@ -3039,32 +3070,33 @@ var JobsConfigSchema = Type.Object({
3039
3070
  { maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) },
3040
3071
  { default: {} }
3041
3072
  ),
3042
- // 2.F6 generation lifecycle knobs (jobs.md §11) — mirrors the worker-local
3043
- // GENERATION_DEFAULTS in workers/jobs-v1/src/generation.ts (its
3044
- // resolveGenerationConfig reads `loaded.generation` and clamps to these same
3045
- // bounds when a field is absent).
3073
+ // 2.F6 generation lifecycle knobs (jobs.md §11). The defaults and bounds are
3074
+ // declared ONCE, as GENERATION_DEFAULTS / GENERATION_BOUNDS below this schema
3075
+ // (2026-09-25): jobs-v1's resolveGenerationConfig imports them and clamps a
3076
+ // pre-fold manifest to the same numbers — a hand-mirrored copy used to live
3077
+ // in workers/jobs-v1/src/generation.ts.
3046
3078
  generation: Type.Object(
3047
3079
  {
3048
3080
  /** per-tenant in-flight generation cap (separate budget from queue jobs) */
3049
- maxConcurrent: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
3081
+ maxConcurrent: Type.Integer({ default: GENERATION_DEFAULTS.maxConcurrent, minimum: GENERATION_BOUNDS.maxConcurrent.min, maximum: GENERATION_BOUNDS.maxConcurrent.max }),
3050
3082
  /** default expiry/timeout when the descriptor omits one */
3051
- defaultTimeoutMs: Type.Integer({ default: 3e5, minimum: 1e3, maximum: 36e5 }),
3083
+ defaultTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.defaultTimeoutMs, minimum: GENERATION_BOUNDS.defaultTimeoutMs.min, maximum: GENERATION_BOUNDS.defaultTimeoutMs.max }),
3052
3084
  /** hard ceiling a tenant-supplied timeout is clamped to */
3053
- maxTimeoutMs: Type.Integer({ default: 36e5, minimum: 1e3, maximum: 36e5 }),
3085
+ maxTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.maxTimeoutMs, minimum: GENERATION_BOUNDS.maxTimeoutMs.min, maximum: GENERATION_BOUNDS.maxTimeoutMs.max }),
3054
3086
  /** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
3055
- pollMaxAttempts: Type.Integer({ default: 60, minimum: 1, maximum: 1e3 }),
3087
+ pollMaxAttempts: Type.Integer({ default: GENERATION_DEFAULTS.pollMaxAttempts, minimum: GENERATION_BOUNDS.pollMaxAttempts.min, maximum: GENERATION_BOUNDS.pollMaxAttempts.max }),
3056
3088
  /** MANDATORY per-hold cap on a generation `reserve_credits.amount` (jobs.md
3057
3089
  * §11.8). Every requested amount is CLAMPED to this (never rejected) — a
3058
3090
  * conservative default so an untrusted deployed function that carries a
3059
3091
  * reserve block can never hold more than a bounded amount per run without
3060
3092
  * any tenant action. */
3061
- maxReserveCredits: Type.Integer({ default: 1e3, minimum: 1, maximum: 1e6 }),
3093
+ maxReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxReserveCredits, minimum: GENERATION_BOUNDS.maxReserveCredits.min, maximum: GENERATION_BOUNDS.maxReserveCredits.max }),
3062
3094
  /** MANDATORY per-tenant ceiling on the SUM of un-settled provisional
3063
3095
  * reserve holds across all in-flight generation runs (jobs.md §11.8): a
3064
3096
  * reserve whose amount would push the tenant's outstanding-holds total over
3065
3097
  * this is rejected 429, so a runaway function cannot hold every user at
3066
3098
  * once. Defaulted so no tenant action is required to be safe. */
3067
- maxOutstandingReserveCredits: Type.Integer({ default: 1e5, minimum: 1, maximum: 1e8 })
3099
+ maxOutstandingReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxOutstandingReserveCredits, minimum: GENERATION_BOUNDS.maxOutstandingReserveCredits.min, maximum: GENERATION_BOUNDS.maxOutstandingReserveCredits.max })
3068
3100
  },
3069
3101
  { default: {} }
3070
3102
  )
@@ -3422,9 +3454,9 @@ var WebhooksConfigSchema = Type.Object({
3422
3454
  })),
3423
3455
  // DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's OUTBOUND
3424
3456
  // subscriptions as config. Keyed by `target_url` — the only stable identity a
3425
- // subscription has (there is no name column and no update route, so a changed
3426
- // prefix set is delete+recreate, exactly what the function-trigger reconciler
3427
- // does). `vxil push` / `POST /v1/apply` converge public.webhook_subscriptions
3457
+ // subscription has (there is no name column). A changed prefix set is an
3458
+ // in-place update — PATCH /v1/webhooks/subscriptions/:subId, same sub_id and
3459
+ // cursor (2026-09-25). `vxil push` / `POST /v1/apply` converge public.webhook_subscriptions
3428
3460
  // onto this list (handlers/apiState.ts); undeclared live rows are LEFT and
3429
3461
  // reported (deleted only under --allow-destructive). Rows on the platform's
3430
3462
  // signed function-delivery lanes (/v1/internal/fn/…) are NEVER declared here
@@ -4006,17 +4038,31 @@ var PaymentsConfigSchema = Type.Object({
4006
4038
  // analyzer's top level; productMap/tierMap are tenant-supplied Type.Record
4007
4039
  // MAPS (one typed leaf each), so catalog size never inflates the flag count.
4008
4040
  ledger: Type.Optional(Type.Object({
4009
- productMap: Type.Record(Type.String(), Type.Object({
4010
- // product_id → grant rule
4011
- creditType: Type.String({ minLength: 1 }),
4012
- amount: Type.Integer({ minimum: 1 }),
4013
- // #128: a grant only ADDS
4014
- period: Type.Union([
4015
- Type.Literal("once"),
4016
- Type.Literal("monthly"),
4017
- Type.Literal("annual")
4018
- ])
4019
- })),
4041
+ // product_id → what the purchase GRANTS. ONE Type.Record leaf (the rag
4042
+ // `boosts` Record-of-Union precedent) with two rule shapes:
4043
+ // { creditType, amount, period } a credit grant (the original rule)
4044
+ // { tier, durationDays } (2026-09-25 F35+) a TIME-BOXED
4045
+ // ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
4046
+ // below) for `durationDays`, as a charge-linked manual-style row that
4047
+ // STACKS on the user's live purchases of the same tier and is ENDED by
4048
+ // that charge's full refund / chargeback. No defaults in either shape,
4049
+ // so an existing manifest is byte-identical after Value.Default.
4050
+ productMap: Type.Record(Type.String(), Type.Union([
4051
+ Type.Object({
4052
+ creditType: Type.String({ minLength: 1 }),
4053
+ amount: Type.Integer({ minimum: 1 }),
4054
+ // #128: a grant only ADDS
4055
+ period: Type.Union([
4056
+ Type.Literal("once"),
4057
+ Type.Literal("monthly"),
4058
+ Type.Literal("annual")
4059
+ ])
4060
+ }),
4061
+ Type.Object({
4062
+ tier: Type.String({ minLength: 1 }),
4063
+ durationDays: Type.Integer({ minimum: 1, maximum: 3650 })
4064
+ })
4065
+ ])),
4020
4066
  tierMap: Type.Record(Type.String(), Type.Object({
4021
4067
  // tier → entitlement/quota/grant
4022
4068
  entitlements: Type.Array(Type.String()),
@@ -4150,8 +4196,17 @@ var FunctionsConfigSchema = Type.Object({
4150
4196
  // webhook/queue: source/queue id
4151
4197
  collection: Type.Optional(Type.String()),
4152
4198
  // cmsHook: the CMS collection slug
4153
- event: Type.Optional(Type.String())
4199
+ event: Type.Optional(Type.String()),
4154
4200
  // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
4201
+ // F33 (2026-09-25): the per-binding opt-in to re-delivery on
4202
+ // queue / webhook / cmsHook / authHook (the cross-field rule
4203
+ // rejects it on http / cron). Absent = the ACK-200 default. The
4204
+ // receiver answers a failed attempt as an enveloped 503 (ladder)
4205
+ // and the last one as a terminal 409 (dead + job.dead_lettered);
4206
+ // effective attempts = min(maxAttempts, jobs.retry.defaultMaxAttempts).
4207
+ retry: Type.Optional(Type.Object({
4208
+ maxAttempts: Type.Integer({ minimum: 1, maximum: FN_RETRY_MAX_ATTEMPTS })
4209
+ }))
4155
4210
  }),
4156
4211
  { maxItems: 8 }
4157
4212
  )
@@ -4307,6 +4362,7 @@ var CopilotConfigSchema = Type.Object({
4307
4362
  var DENY_FUNCTION_SCOPES = /* @__PURE__ */ new Set(["admin", "*", "features:write", "functions:write", "secrets:write"]);
4308
4363
 
4309
4364
  // src/lib.ts
4365
+ var MAX_FN_BINDINGS = 8;
4310
4366
  var FN_LIMIT_BOUNDS = {
4311
4367
  cpuMs: { min: 5, max: 3e5 },
4312
4368
  timeoutMs: { min: 1e3, max: 12e4 }
@@ -4325,24 +4381,39 @@ function normalizeFnLimits(raw) {
4325
4381
  function lowerTriggerBindings(trigger) {
4326
4382
  const t = trigger?.kind ?? "http";
4327
4383
  const str2 = (k) => typeof trigger?.[k] === "string" ? trigger[k] : void 0;
4328
- if (t === "cron") return [{ kind: "cron", ...str2("schedule") ? { schedule: str2("schedule") } : {} }];
4329
- if (t === "queue") return [{ kind: "queue", ...str2("source") ? { source: str2("source") } : {} }];
4330
- if (t === "webhook") return [{ kind: "webhook", ...str2("source") ? { source: str2("source") } : {} }];
4384
+ const retryOf = () => {
4385
+ const r = trigger?.retry;
4386
+ const n = r && typeof r === "object" ? r.maxAttempts : void 0;
4387
+ return typeof n === "number" && Number.isInteger(n) ? { retry: { maxAttempts: n } } : {};
4388
+ };
4389
+ if (t === "cron") return [{ kind: "cron", ...str2("schedule") ? { schedule: str2("schedule") } : {}, ...retryOf() }];
4390
+ if (t === "queue") return [{ kind: "queue", ...str2("source") ? { source: str2("source") } : {}, ...retryOf() }];
4391
+ if (t === "webhook") return [{ kind: "webhook", ...str2("source") ? { source: str2("source") } : {}, ...retryOf() }];
4331
4392
  if (t === "cmsHook" || t === "cms-hook") {
4332
4393
  return [{
4333
4394
  kind: "cmsHook",
4334
4395
  ...str2("collection") ? { collection: str2("collection") } : {},
4335
- ...str2("event") ? { event: str2("event") } : {}
4396
+ ...str2("event") ? { event: str2("event") } : {},
4397
+ ...retryOf()
4336
4398
  }];
4337
4399
  }
4338
4400
  if (t === "authHook" || t === "auth-hook") {
4339
- return [{ kind: "authHook", ...str2("event") ? { event: str2("event") } : {} }];
4401
+ return [{ kind: "authHook", ...str2("event") ? { event: str2("event") } : {}, ...retryOf() }];
4402
+ }
4403
+ return [{ kind: "http", ...str2("path") ? { path: str2("path") } : {}, ...retryOf() }];
4404
+ }
4405
+ function lowerAllTriggerBindings(def, name) {
4406
+ const declared = [def.trigger, ...def.triggers ?? []].filter((t) => Boolean(t));
4407
+ if (!declared.length) return lowerTriggerBindings(void 0);
4408
+ const bindings = declared.flatMap((t) => lowerTriggerBindings(t));
4409
+ if (bindings.length > MAX_FN_BINDINGS) {
4410
+ throw new Error(`functions${name ? `.${name}` : ""}: ${bindings.length} trigger bindings declared (trigger + triggers[]) \u2014 the cap is ${MAX_FN_BINDINGS}`);
4340
4411
  }
4341
- return [{ kind: "http", ...str2("path") ? { path: str2("path") } : {} }];
4412
+ return bindings;
4342
4413
  }
4343
4414
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
4344
- var VXIL_CONFIG_PKG_VERSION = "0.5.1";
4345
- var VXIL_SDK_PKG_VERSION = "0.7.0";
4415
+ var VXIL_CONFIG_PKG_VERSION = "0.6.0";
4416
+ var VXIL_SDK_PKG_VERSION = "0.8.0";
4346
4417
  function ensureScaffoldPackageJson(cwd, opts = {}) {
4347
4418
  const file = resolve(cwd, "package.json");
4348
4419
  const wanted = {
@@ -4377,6 +4448,12 @@ function ensureScaffoldPackageJson(cwd, opts = {}) {
4377
4448
  writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n");
4378
4449
  return { action: "added" };
4379
4450
  }
4451
+ function apiErrText(r) {
4452
+ const e = r.body.error;
4453
+ if (!e) return `${r.status}`;
4454
+ return `${e.code ?? r.status}${e.message ? ` \u2014 ${e.message}` : ""}${e.hint ? `
4455
+ hint: ${e.hint}` : ""}`;
4456
+ }
4380
4457
  var CONFIG_IMPORT_SPECIFIERS = ["@vxil/config", "vxil/config"];
4381
4458
  function ownConfigImpl() {
4382
4459
  try {
@@ -4566,7 +4643,7 @@ function assembleApplyBundle(cfg, fnSources) {
4566
4643
  const functions = Object.entries(cfg.functions ?? {}).map(([name, def]) => {
4567
4644
  const source = fnSources[name];
4568
4645
  if (source === void 0) throw new Error(`functions: no bundled source for '${name}'`);
4569
- const bindings = lowerTriggerBindings(def.trigger);
4646
+ const bindings = lowerAllTriggerBindings(def, name);
4570
4647
  const limits = normalizeFnLimits(def.limits);
4571
4648
  return {
4572
4649
  name,
@@ -4703,616 +4780,320 @@ function formatNotCompared(u, hint) {
4703
4780
  return ` ! not compared: ${u.route} \u2192 ${u.code ?? u.status}${u.message ? ` \u2014 ${u.message}` : ""}${hint ? ` (${hint})` : ""}`;
4704
4781
  }
4705
4782
 
4706
- // ../types/src/ulid.ts
4707
- var ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
4708
- function ulid(now = Date.now()) {
4709
- let time = "";
4710
- let t = now;
4711
- for (let i = 0; i < 10; i++) {
4712
- time = ALPHABET[t % 32] + time;
4713
- t = Math.floor(t / 32);
4783
+ // ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/index.js
4784
+ import os from "os";
4785
+ import fs from "fs";
4786
+
4787
+ // ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/query.js
4788
+ var originCache = /* @__PURE__ */ new Map();
4789
+ var originStackCache = /* @__PURE__ */ new Map();
4790
+ var originError = /* @__PURE__ */ Symbol("OriginError");
4791
+ var CLOSE = {};
4792
+ var Query = class extends Promise {
4793
+ constructor(strings, args, handler, canceller, options = {}) {
4794
+ let resolve8, reject;
4795
+ super((a, b2) => {
4796
+ resolve8 = a;
4797
+ reject = b2;
4798
+ });
4799
+ this.tagged = Array.isArray(strings.raw);
4800
+ this.strings = strings;
4801
+ this.args = args;
4802
+ this.handler = handler;
4803
+ this.canceller = canceller;
4804
+ this.options = options;
4805
+ this.state = null;
4806
+ this.statement = null;
4807
+ this.resolve = (x) => (this.active = false, resolve8(x));
4808
+ this.reject = (x) => (this.active = false, reject(x));
4809
+ this.active = false;
4810
+ this.cancelled = null;
4811
+ this.executed = false;
4812
+ this.signature = "";
4813
+ this[originError] = this.handler.debug ? new Error() : this.tagged && cachedError(this.strings);
4714
4814
  }
4715
- const rand = new Uint8Array(16);
4716
- crypto.getRandomValues(rand);
4717
- let out = time;
4718
- for (let i = 0; i < 16; i++) {
4719
- out += ALPHABET[rand[i] % 32];
4815
+ get origin() {
4816
+ return (this.handler.debug ? this[originError].stack : this.tagged && originStackCache.has(this.strings) ? originStackCache.get(this.strings) : originStackCache.set(this.strings, this[originError].stack).get(this.strings)) || "";
4720
4817
  }
4721
- return out;
4818
+ static get [Symbol.species]() {
4819
+ return Promise;
4820
+ }
4821
+ cancel() {
4822
+ return this.canceller && (this.canceller(this), this.canceller = null);
4823
+ }
4824
+ simple() {
4825
+ this.options.simple = true;
4826
+ this.options.prepare = false;
4827
+ return this;
4828
+ }
4829
+ async readable() {
4830
+ this.simple();
4831
+ this.streaming = true;
4832
+ return this;
4833
+ }
4834
+ async writable() {
4835
+ this.simple();
4836
+ this.streaming = true;
4837
+ return this;
4838
+ }
4839
+ cursor(rows = 1, fn) {
4840
+ this.options.simple = false;
4841
+ if (typeof rows === "function") {
4842
+ fn = rows;
4843
+ rows = 1;
4844
+ }
4845
+ this.cursorRows = rows;
4846
+ if (typeof fn === "function")
4847
+ return this.cursorFn = fn, this;
4848
+ let prev;
4849
+ return {
4850
+ [Symbol.asyncIterator]: () => ({
4851
+ next: () => {
4852
+ if (this.executed && !this.active)
4853
+ return { done: true };
4854
+ prev && prev();
4855
+ const promise = new Promise((resolve8, reject) => {
4856
+ this.cursorFn = (value) => {
4857
+ resolve8({ value, done: false });
4858
+ return new Promise((r) => prev = r);
4859
+ };
4860
+ this.resolve = () => (this.active = false, resolve8({ done: true }));
4861
+ this.reject = (x) => (this.active = false, reject(x));
4862
+ });
4863
+ this.execute();
4864
+ return promise;
4865
+ },
4866
+ return() {
4867
+ prev && prev(CLOSE);
4868
+ return { done: true };
4869
+ }
4870
+ })
4871
+ };
4872
+ }
4873
+ describe() {
4874
+ this.options.simple = false;
4875
+ this.onlyDescribe = this.options.prepare = true;
4876
+ return this;
4877
+ }
4878
+ stream() {
4879
+ throw new Error(".stream has been renamed to .forEach");
4880
+ }
4881
+ forEach(fn) {
4882
+ this.forEachFn = fn;
4883
+ this.handle();
4884
+ return this;
4885
+ }
4886
+ raw() {
4887
+ this.isRaw = true;
4888
+ return this;
4889
+ }
4890
+ values() {
4891
+ this.isRaw = "values";
4892
+ return this;
4893
+ }
4894
+ async handle() {
4895
+ !this.executed && (this.executed = true) && await 1 && this.handler(this);
4896
+ }
4897
+ execute() {
4898
+ this.handle();
4899
+ return this;
4900
+ }
4901
+ then() {
4902
+ this.handle();
4903
+ return super.then.apply(this, arguments);
4904
+ }
4905
+ catch() {
4906
+ this.handle();
4907
+ return super.catch.apply(this, arguments);
4908
+ }
4909
+ finally() {
4910
+ this.handle();
4911
+ return super.finally.apply(this, arguments);
4912
+ }
4913
+ };
4914
+ function cachedError(xs) {
4915
+ if (originCache.has(xs))
4916
+ return originCache.get(xs);
4917
+ const x = Error.stackTraceLimit;
4918
+ Error.stackTraceLimit = 4;
4919
+ originCache.set(xs, new Error());
4920
+ Error.stackTraceLimit = x;
4921
+ return originCache.get(xs);
4722
4922
  }
4723
- function newId(prefix) {
4724
- return `${prefix}_${ulid()}`;
4923
+
4924
+ // ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/errors.js
4925
+ var PostgresError = class extends Error {
4926
+ constructor(x) {
4927
+ super(x.message);
4928
+ this.name = this.constructor.name;
4929
+ Object.assign(this, x);
4930
+ }
4931
+ };
4932
+ var Errors = {
4933
+ connection,
4934
+ postgres,
4935
+ generic,
4936
+ notSupported
4937
+ };
4938
+ function connection(x, options, socket) {
4939
+ const { host, port } = socket || options;
4940
+ const error = Object.assign(
4941
+ new Error("write " + x + " " + (options.path || host + ":" + port)),
4942
+ {
4943
+ code: x,
4944
+ errno: x,
4945
+ address: options.path || host
4946
+ },
4947
+ options.path ? {} : { port }
4948
+ );
4949
+ Error.captureStackTrace(error, connection);
4950
+ return error;
4951
+ }
4952
+ function postgres(x) {
4953
+ const error = new PostgresError(x);
4954
+ Error.captureStackTrace(error, postgres);
4955
+ return error;
4956
+ }
4957
+ function generic(code, message) {
4958
+ const error = Object.assign(new Error(code + ": " + message), { code });
4959
+ Error.captureStackTrace(error, generic);
4960
+ return error;
4961
+ }
4962
+ function notSupported(x) {
4963
+ const error = Object.assign(
4964
+ new Error(x + " (B) is not supported"),
4965
+ {
4966
+ code: "MESSAGE_NOT_SUPPORTED",
4967
+ name: x
4968
+ }
4969
+ );
4970
+ Error.captureStackTrace(error, notSupported);
4971
+ return error;
4725
4972
  }
4726
4973
 
4727
- // ../types/src/index.ts
4728
- var KNOWN_SCOPES = [
4729
- "features:read",
4730
- "features:write",
4731
- // Dedicated write scope for the BYO-secret path. PUT /v1/secrets/:feature/:name
4732
- // requires THIS (or 'admin') as of 2026-07-17 (audit H2 — 'features:write' is no
4733
- // longer secret-write-equivalent). GET/DELETE /v1/secrets still also accept
4734
- // 'features:write'.
4735
- "secrets:write",
4736
- "users:read",
4737
- "users:write",
4738
- "notifications:send",
4739
- "notifications:read",
4740
- "jobs:read",
4741
- "jobs:write",
4742
- "auth:read",
4743
- "auth:write",
4744
- // Narrow least-privilege scope for the SIGN-IN surface only (2026-09-10,
4745
- // F4-25/F8-56): every auth route an anonymous or self-authenticating client
4746
- // must call to obtain, renew, verify or end ITS OWN session (sign-up/sign-in,
4747
- // magic-link, OTP, anonymous, step-up, OAuth, refresh, by-token revoke,
4748
- // sessions/verify, password reset). It does NOT reach the administrative
4749
- // routes — GET /v1/auth/sessions (auth:read), POST /v1/auth/sessions/:id/
4750
- // revoke and POST /v1/auth/users/:id/erase (auth:write). This is the scope a
4751
- // key baked into a browser/mobile bundle carries for sign-in; 'auth:write'
4752
- // still satisfies every route (any-of, the usage:read idiom) so existing keys
4753
- // keep working.
4754
- "auth:signin",
4755
- "ratelimits:read",
4756
- "ratelimits:write",
4757
- "ratelimits:check",
4758
- "files:read",
4759
- "files:write",
4760
- "webhooks:read",
4761
- "webhooks:write",
4762
- "comments:read",
4763
- "comments:write",
4764
- "cms:read",
4765
- "cms:write",
4766
- "realtime:read",
4767
- "realtime:write",
4768
- "presence:read",
4769
- "orgs:read",
4770
- "orgs:write",
4771
- "payments:read",
4772
- "payments:write",
4773
- "vector-search:read",
4774
- "vector-search:write",
4775
- "ai:read",
4776
- "ai:write",
4777
- "rag:read",
4778
- "rag:write",
4779
- "activity-feed:read",
4780
- "activity-feed:write",
4781
- "functions:invoke",
4782
- "functions:read",
4783
- "functions:write",
4784
- "copilot:read",
4785
- "copilot:write",
4786
- // Narrow least-privilege read for GET /v1/usage (2026-08-05, parity P0#4).
4787
- // The route ALSO accepts 'features:read' (any-of, the secrets:write idiom)
4788
- // so pre-existing keys keep working; mint this when a key should see usage
4789
- // and nothing else.
4790
- "usage:read",
4791
- "admin"
4792
- ];
4793
- var FUNCTION_SCOPE_AUDIENCE = {
4794
- cms: "cms",
4795
- payments: "payments",
4796
- notifications: "notifications",
4797
- comments: "comments",
4798
- files: "files",
4799
- ai: "ai",
4800
- rag: "rag",
4801
- "vector-search": "vector-search",
4802
- "activity-feed": "activity-feed",
4803
- orgs: "orgs",
4804
- auth: "auth",
4805
- jobs: "jobs",
4806
- realtime: "realtime",
4807
- presence: "realtime",
4808
- // presence:read is served by the realtime worker
4809
- ratelimits: "rate-limits",
4810
- // the worker checks `ratelimits:*`; its audience is 'rate-limits'
4811
- // The end-user REGISTRY (/v1/users*) and the usage meter (/v1/usage) are
4812
- // control-plane routes. The edge already re-mints a function's bearer for
4813
- // aud='control-plane' exactly as it does for a feature worker, and the
4814
- // control plane verifies that audience + scope-checks per route — so this
4815
- // is a contained audience. The minted claim is CONFINED to
4816
- // CONTROL_PLANE_FUNCTION_SCOPES (scopesForFunctionAudience) so the same token
4817
- // can never satisfy any other scope-gated control-plane route.
4818
- users: "control-plane",
4819
- usage: "control-plane"
4974
+ // ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/types.js
4975
+ var types = {
4976
+ string: {
4977
+ to: 25,
4978
+ from: null,
4979
+ // defaults to string
4980
+ serialize: (x) => "" + x
4981
+ },
4982
+ number: {
4983
+ to: 0,
4984
+ from: [21, 23, 26, 700, 701],
4985
+ serialize: (x) => "" + x,
4986
+ parse: (x) => +x
4987
+ },
4988
+ json: {
4989
+ to: 114,
4990
+ from: [114, 3802],
4991
+ serialize: (x) => JSON.stringify(x),
4992
+ parse: (x) => JSON.parse(x)
4993
+ },
4994
+ boolean: {
4995
+ to: 16,
4996
+ from: 16,
4997
+ serialize: (x) => x === true ? "t" : "f",
4998
+ parse: (x) => x === "t"
4999
+ },
5000
+ date: {
5001
+ to: 1184,
5002
+ from: [1082, 1114, 1184],
5003
+ serialize: (x) => (x instanceof Date ? x : new Date(x)).toISOString(),
5004
+ parse: (x) => new Date(x)
5005
+ },
5006
+ bytea: {
5007
+ to: 17,
5008
+ from: 17,
5009
+ serialize: (x) => "\\x" + Buffer.from(x).toString("hex"),
5010
+ parse: (x) => Buffer.from(x.slice(2), "hex")
5011
+ }
4820
5012
  };
4821
- var FUNCTION_SCOPE_PREFIX_ALIASES = {
4822
- "rate-limits": "ratelimits",
4823
- search: "vector-search",
4824
- vector: "vector-search",
4825
- feeds: "activity-feed"
5013
+ var NotTagged = class {
5014
+ then() {
5015
+ notTagged();
5016
+ }
5017
+ catch() {
5018
+ notTagged();
5019
+ }
5020
+ finally() {
5021
+ notTagged();
5022
+ }
4826
5023
  };
4827
- var CONTROL_PLANE_FUNCTION_SCOPES = ["users:read", "users:write", "usage:read"];
4828
- function canonicalFunctionScope(scope) {
4829
- const i = scope.indexOf(":");
4830
- if (i <= 0) return scope;
4831
- const prefix = scope.slice(0, i);
4832
- const canonical2 = FUNCTION_SCOPE_PREFIX_ALIASES[prefix];
4833
- return canonical2 ? `${canonical2}${scope.slice(i)}` : scope;
4834
- }
4835
- function functionScopeAudience(scope) {
4836
- const c = canonicalFunctionScope(scope);
4837
- const i = c.indexOf(":");
4838
- if (i <= 0) return null;
4839
- return FUNCTION_SCOPE_AUDIENCE[c.slice(0, i)] ?? null;
5024
+ var Identifier = class extends NotTagged {
5025
+ constructor(value) {
5026
+ super();
5027
+ this.value = escapeIdentifier(value);
5028
+ }
5029
+ };
5030
+ var Parameter = class extends NotTagged {
5031
+ constructor(value, type, array) {
5032
+ super();
5033
+ this.value = value;
5034
+ this.type = type;
5035
+ this.array = array;
5036
+ }
5037
+ };
5038
+ var Builder = class extends NotTagged {
5039
+ constructor(first, rest2) {
5040
+ super();
5041
+ this.first = first;
5042
+ this.rest = rest2;
5043
+ }
5044
+ build(before, parameters, types2, options) {
5045
+ const keyword = builders.map(([x, fn]) => ({ fn, i: before.search(x) })).sort((a, b2) => a.i - b2.i).pop();
5046
+ return keyword.i === -1 ? escapeIdentifiers(this.first, options) : keyword.fn(this.first, this.rest, parameters, types2, options);
5047
+ }
5048
+ };
5049
+ function handleValue(x, parameters, types2, options) {
5050
+ let value = x instanceof Parameter ? x.value : x;
5051
+ if (value === void 0) {
5052
+ x instanceof Parameter ? x.value = options.transform.undefined : value = x = options.transform.undefined;
5053
+ if (value === void 0)
5054
+ throw Errors.generic("UNDEFINED_VALUE", "Undefined values are not allowed");
5055
+ }
5056
+ return "$" + types2.push(
5057
+ x instanceof Parameter ? (parameters.push(x.value), x.array ? x.array[x.type || inferType(x.value)] || x.type || firstIsString(x.value) : x.type) : (parameters.push(x), inferType(x))
5058
+ );
4840
5059
  }
4841
- function functionCallbackAudiences(scopes) {
4842
- const out = [];
4843
- for (const s of scopes) {
4844
- const a = functionScopeAudience(s);
4845
- if (a && !out.includes(a)) out.push(a);
5060
+ var defaultHandlers = typeHandlers(types);
5061
+ function stringify(q, string, value, parameters, types2, options) {
5062
+ for (let i = 1; i < q.strings.length; i++) {
5063
+ string += stringifyValue(string, value, parameters, types2, options) + q.strings[i];
5064
+ value = q.args[i];
4846
5065
  }
4847
- return out;
5066
+ return string;
4848
5067
  }
4849
- function scopesForFunctionAudience(aud, scopes) {
4850
- const canon = [...new Set(scopes.map(canonicalFunctionScope))];
4851
- if (aud === "control-plane") return canon.filter((s) => CONTROL_PLANE_FUNCTION_SCOPES.includes(s));
4852
- return canon;
5068
+ function stringifyValue(string, value, parameters, types2, o) {
5069
+ return value instanceof Builder ? value.build(string, parameters, types2, o) : value instanceof Query ? fragment(value, parameters, types2, o) : value instanceof Identifier ? value.value : value && value[0] instanceof Query ? value.reduce((acc, x) => acc + " " + fragment(x, parameters, types2, o), "") : handleValue(value, parameters, types2, o);
4853
5070
  }
4854
- var FUNCTION_SOURCE_MAX_BYTES = 512e3;
4855
-
4856
- // ../runtime/src/hash.ts
4857
- async function sha256Hex(input) {
4858
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
4859
- return [...new Uint8Array(digest)].map((b2) => b2.toString(16).padStart(2, "0")).join("");
5071
+ function fragment(q, parameters, types2, options) {
5072
+ q.fragment = true;
5073
+ return stringify(q, q.strings[0], q.args[0], parameters, types2, options);
4860
5074
  }
4861
- async function hmacSha256Hex(secret, data4) {
4862
- const key = await crypto.subtle.importKey(
4863
- "raw",
4864
- new TextEncoder().encode(secret),
4865
- { name: "HMAC", hash: "SHA-256" },
4866
- false,
4867
- ["sign"]
4868
- );
4869
- const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data4));
4870
- return [...new Uint8Array(sig)].map((b2) => b2.toString(16).padStart(2, "0")).join("");
5075
+ function valuesBuilder(first, parameters, types2, columns, options) {
5076
+ return first.map(
5077
+ (row2) => "(" + columns.map(
5078
+ (column) => stringifyValue("values", row2[column], parameters, types2, options)
5079
+ ).join(",") + ")"
5080
+ ).join(",");
4871
5081
  }
4872
- var AI_TEMPLATE_SCHEMA_MAX_DEPTH = 64;
4873
- async function aiTemplateContentSha256(t) {
4874
- const canon = (v, depth) => {
4875
- if (v === null || typeof v !== "object") return v;
4876
- if (depth > AI_TEMPLATE_SCHEMA_MAX_DEPTH) throw new RangeError(`schema nests deeper than ${AI_TEMPLATE_SCHEMA_MAX_DEPTH} levels`);
4877
- if (Array.isArray(v)) return v.map((x) => canon(x, depth + 1));
4878
- const o = v;
4879
- return Object.fromEntries(Object.keys(o).sort().map((k) => [k, canon(o[k], depth + 1)]));
4880
- };
4881
- return sha256Hex(JSON.stringify({
4882
- schema: canon(t.schema ?? null, 1),
4883
- system: t.system ?? null,
4884
- user: t.user
4885
- }));
5082
+ function values(first, rest2, parameters, types2, options) {
5083
+ const multi = Array.isArray(first[0]);
5084
+ const columns = rest2.length ? rest2.flat() : Object.keys(multi ? first[0] : first);
5085
+ return valuesBuilder(multi ? first : [first], parameters, types2, columns, options);
4886
5086
  }
4887
-
4888
- // ../runtime/src/egress.ts
4889
- function isPrivateIPv4(host) {
4890
- const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
4891
- if (!m) return false;
4892
- const o = m.slice(1).map(Number);
4893
- if (o.some((n) => n > 255)) return false;
4894
- const [a, b2] = o;
4895
- return a === 10 || // 10.0.0.0/8 RFC1918
4896
- a === 172 && b2 >= 16 && b2 <= 31 || // 172.16.0.0/12 RFC1918
4897
- a === 192 && b2 === 168 || // 192.168.0.0/16 RFC1918
4898
- a === 127 || // 127.0.0.0/8 loopback
4899
- a === 169 && b2 === 254 || // 169.254.0.0/16 link-local (incl. metadata)
4900
- a === 100 && b2 >= 64 && b2 <= 127 || // 100.64.0.0/10 CGNAT / shared
4901
- a === 0 || // 0.0.0.0/8 "this host"
4902
- a >= 224;
4903
- }
4904
- function expandIPv6(host) {
4905
- if (!/^[0-9a-f:.]+$/.test(host) || !host.includes(":")) return null;
4906
- let h = host;
4907
- let tail = [];
4908
- const lastColon = h.lastIndexOf(":");
4909
- const maybeV4 = h.slice(lastColon + 1);
4910
- if (maybeV4.includes(".")) {
4911
- const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(maybeV4);
4912
- if (!m) return null;
4913
- const q = m.slice(1).map(Number);
4914
- if (q.some((n) => n > 255)) return null;
4915
- tail = [q[0] << 8 | q[1], q[2] << 8 | q[3]];
4916
- h = h.slice(0, lastColon + 1) + "0:0";
4917
- }
4918
- const parts = h.split("::");
4919
- if (parts.length > 2) return null;
4920
- const head = parts[0] ? parts[0].split(":") : [];
4921
- const rest2 = parts.length === 2 ? parts[1] ? parts[1].split(":") : [] : null;
4922
- let groups;
4923
- if (rest2 === null) {
4924
- groups = head;
4925
- } else {
4926
- const fill = 8 - head.length - rest2.length - tail.length;
4927
- if (fill < 0) return null;
4928
- groups = [...head, ...Array(fill).fill("0"), ...rest2];
4929
- }
4930
- const nums = groups.map((g) => g === "" ? 0 : parseInt(g, 16));
4931
- const all = [...nums, ...tail];
4932
- if (all.length !== 8 || all.some((n) => !Number.isFinite(n) || n < 0 || n > 65535)) {
4933
- return null;
4934
- }
4935
- return all.map((n) => n.toString(16));
4936
- }
4937
- function isPrivateIPv6(host) {
4938
- const hextets = expandIPv6(host);
4939
- if (!hextets) return false;
4940
- const n = hextets.map((x) => parseInt(x, 16));
4941
- const [h0, h1] = n;
4942
- if (n.every((x) => x === 0)) return true;
4943
- if (n.slice(0, 7).every((x) => x === 0) && n[7] === 1) return true;
4944
- if ((h0 & 65024) === 64512) return true;
4945
- if ((h0 & 65472) === 65152) return true;
4946
- if (n.slice(0, 5).every((x) => x === 0) && h0 === 0 && n[5] === 65535) {
4947
- const a = n[6] >> 8 & 255;
4948
- const b2 = n[6] & 255;
4949
- const c = n[7] >> 8 & 255;
4950
- const d = n[7] & 255;
4951
- return isPrivateIPv4(`${a}.${b2}.${c}.${d}`);
4952
- }
4953
- if (h0 === 100 && h1 === 65435) {
4954
- const a = n[6] >> 8 & 255;
4955
- const b2 = n[6] & 255;
4956
- const c = n[7] >> 8 & 255;
4957
- const d = n[7] & 255;
4958
- return isPrivateIPv4(`${a}.${b2}.${c}.${d}`);
4959
- }
4960
- return false;
4961
- }
4962
- var VXIL_API_HOSTS = /* @__PURE__ */ new Set(["api.vxil.com", "api.vxil.org"]);
4963
- function egressDecision(targetUrl, allowHosts = []) {
4964
- let u;
4965
- try {
4966
- u = new URL(targetUrl);
4967
- } catch {
4968
- return { allow: false, reason: "invalid-url" };
4969
- }
4970
- if (u.protocol !== "https:") return { allow: false, reason: `non-https:${u.protocol}` };
4971
- if (u.username !== "" || u.password !== "") return { allow: false, reason: "embedded-credentials" };
4972
- const host = u.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "");
4973
- if (host === "") return { allow: false, reason: "empty-host" };
4974
- if (isPrivateIPv4(host) || isPrivateIPv6(host)) return { allow: false, reason: `private-ip:${host}` };
4975
- if (host === "localhost" || host.endsWith(".localhost") || host === "metadata.google.internal" || host.endsWith(".internal") || host.endsWith(".local")) {
4976
- return { allow: false, reason: `internal-host:${host}` };
4977
- }
4978
- if (host.endsWith(".workers.dev")) return { allow: false, reason: `sibling-worker:${host}` };
4979
- if (VXIL_API_HOSTS.has(host)) return { allow: true, reason: "vxil-api" };
4980
- for (const a of allowHosts) {
4981
- const h = a.toLowerCase().trim();
4982
- if (h && (host === h || host.endsWith("." + h))) return { allow: true, reason: `allowlist:${h}` };
4983
- }
4984
- return { allow: false, reason: `not-allowlisted:${host}` };
4985
- }
4986
-
4987
- // ../runtime/src/jobsCallbackSig.ts
4988
- var JOBS_SIG_HEADER = "X-Vxil-Jobs-Signature";
4989
- function signedString(runId, tenantId, issuedAt, bodyHashHex) {
4990
- return `${runId}.${tenantId}.${issuedAt}.${bodyHashHex}`;
4991
- }
4992
- async function signJobsCallbackWithTenantSecret(tenantSecret, params) {
4993
- const t = params.issuedAt ?? Math.floor(Date.now() / 1e3);
4994
- const bodyHash = await sha256Hex(params.body);
4995
- const v1 = await hmacSha256Hex(
4996
- tenantSecret,
4997
- signedString(params.runId, params.tenantId, t, bodyHash)
4998
- );
4999
- return `t=${t},v1=${v1}`;
5000
- }
5001
-
5002
- // ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/index.js
5003
- import os from "os";
5004
- import fs from "fs";
5005
-
5006
- // ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/query.js
5007
- var originCache = /* @__PURE__ */ new Map();
5008
- var originStackCache = /* @__PURE__ */ new Map();
5009
- var originError = /* @__PURE__ */ Symbol("OriginError");
5010
- var CLOSE = {};
5011
- var Query = class extends Promise {
5012
- constructor(strings, args, handler, canceller, options = {}) {
5013
- let resolve8, reject;
5014
- super((a, b2) => {
5015
- resolve8 = a;
5016
- reject = b2;
5017
- });
5018
- this.tagged = Array.isArray(strings.raw);
5019
- this.strings = strings;
5020
- this.args = args;
5021
- this.handler = handler;
5022
- this.canceller = canceller;
5023
- this.options = options;
5024
- this.state = null;
5025
- this.statement = null;
5026
- this.resolve = (x) => (this.active = false, resolve8(x));
5027
- this.reject = (x) => (this.active = false, reject(x));
5028
- this.active = false;
5029
- this.cancelled = null;
5030
- this.executed = false;
5031
- this.signature = "";
5032
- this[originError] = this.handler.debug ? new Error() : this.tagged && cachedError(this.strings);
5033
- }
5034
- get origin() {
5035
- return (this.handler.debug ? this[originError].stack : this.tagged && originStackCache.has(this.strings) ? originStackCache.get(this.strings) : originStackCache.set(this.strings, this[originError].stack).get(this.strings)) || "";
5036
- }
5037
- static get [Symbol.species]() {
5038
- return Promise;
5039
- }
5040
- cancel() {
5041
- return this.canceller && (this.canceller(this), this.canceller = null);
5042
- }
5043
- simple() {
5044
- this.options.simple = true;
5045
- this.options.prepare = false;
5046
- return this;
5047
- }
5048
- async readable() {
5049
- this.simple();
5050
- this.streaming = true;
5051
- return this;
5052
- }
5053
- async writable() {
5054
- this.simple();
5055
- this.streaming = true;
5056
- return this;
5057
- }
5058
- cursor(rows = 1, fn) {
5059
- this.options.simple = false;
5060
- if (typeof rows === "function") {
5061
- fn = rows;
5062
- rows = 1;
5063
- }
5064
- this.cursorRows = rows;
5065
- if (typeof fn === "function")
5066
- return this.cursorFn = fn, this;
5067
- let prev;
5068
- return {
5069
- [Symbol.asyncIterator]: () => ({
5070
- next: () => {
5071
- if (this.executed && !this.active)
5072
- return { done: true };
5073
- prev && prev();
5074
- const promise = new Promise((resolve8, reject) => {
5075
- this.cursorFn = (value) => {
5076
- resolve8({ value, done: false });
5077
- return new Promise((r) => prev = r);
5078
- };
5079
- this.resolve = () => (this.active = false, resolve8({ done: true }));
5080
- this.reject = (x) => (this.active = false, reject(x));
5081
- });
5082
- this.execute();
5083
- return promise;
5084
- },
5085
- return() {
5086
- prev && prev(CLOSE);
5087
- return { done: true };
5088
- }
5089
- })
5090
- };
5091
- }
5092
- describe() {
5093
- this.options.simple = false;
5094
- this.onlyDescribe = this.options.prepare = true;
5095
- return this;
5096
- }
5097
- stream() {
5098
- throw new Error(".stream has been renamed to .forEach");
5099
- }
5100
- forEach(fn) {
5101
- this.forEachFn = fn;
5102
- this.handle();
5103
- return this;
5104
- }
5105
- raw() {
5106
- this.isRaw = true;
5107
- return this;
5108
- }
5109
- values() {
5110
- this.isRaw = "values";
5111
- return this;
5112
- }
5113
- async handle() {
5114
- !this.executed && (this.executed = true) && await 1 && this.handler(this);
5115
- }
5116
- execute() {
5117
- this.handle();
5118
- return this;
5119
- }
5120
- then() {
5121
- this.handle();
5122
- return super.then.apply(this, arguments);
5123
- }
5124
- catch() {
5125
- this.handle();
5126
- return super.catch.apply(this, arguments);
5127
- }
5128
- finally() {
5129
- this.handle();
5130
- return super.finally.apply(this, arguments);
5131
- }
5132
- };
5133
- function cachedError(xs) {
5134
- if (originCache.has(xs))
5135
- return originCache.get(xs);
5136
- const x = Error.stackTraceLimit;
5137
- Error.stackTraceLimit = 4;
5138
- originCache.set(xs, new Error());
5139
- Error.stackTraceLimit = x;
5140
- return originCache.get(xs);
5141
- }
5142
-
5143
- // ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/errors.js
5144
- var PostgresError = class extends Error {
5145
- constructor(x) {
5146
- super(x.message);
5147
- this.name = this.constructor.name;
5148
- Object.assign(this, x);
5149
- }
5150
- };
5151
- var Errors = {
5152
- connection,
5153
- postgres,
5154
- generic,
5155
- notSupported
5156
- };
5157
- function connection(x, options, socket) {
5158
- const { host, port } = socket || options;
5159
- const error = Object.assign(
5160
- new Error("write " + x + " " + (options.path || host + ":" + port)),
5161
- {
5162
- code: x,
5163
- errno: x,
5164
- address: options.path || host
5165
- },
5166
- options.path ? {} : { port }
5167
- );
5168
- Error.captureStackTrace(error, connection);
5169
- return error;
5170
- }
5171
- function postgres(x) {
5172
- const error = new PostgresError(x);
5173
- Error.captureStackTrace(error, postgres);
5174
- return error;
5175
- }
5176
- function generic(code, message) {
5177
- const error = Object.assign(new Error(code + ": " + message), { code });
5178
- Error.captureStackTrace(error, generic);
5179
- return error;
5180
- }
5181
- function notSupported(x) {
5182
- const error = Object.assign(
5183
- new Error(x + " (B) is not supported"),
5184
- {
5185
- code: "MESSAGE_NOT_SUPPORTED",
5186
- name: x
5187
- }
5188
- );
5189
- Error.captureStackTrace(error, notSupported);
5190
- return error;
5191
- }
5192
-
5193
- // ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/types.js
5194
- var types = {
5195
- string: {
5196
- to: 25,
5197
- from: null,
5198
- // defaults to string
5199
- serialize: (x) => "" + x
5200
- },
5201
- number: {
5202
- to: 0,
5203
- from: [21, 23, 26, 700, 701],
5204
- serialize: (x) => "" + x,
5205
- parse: (x) => +x
5206
- },
5207
- json: {
5208
- to: 114,
5209
- from: [114, 3802],
5210
- serialize: (x) => JSON.stringify(x),
5211
- parse: (x) => JSON.parse(x)
5212
- },
5213
- boolean: {
5214
- to: 16,
5215
- from: 16,
5216
- serialize: (x) => x === true ? "t" : "f",
5217
- parse: (x) => x === "t"
5218
- },
5219
- date: {
5220
- to: 1184,
5221
- from: [1082, 1114, 1184],
5222
- serialize: (x) => (x instanceof Date ? x : new Date(x)).toISOString(),
5223
- parse: (x) => new Date(x)
5224
- },
5225
- bytea: {
5226
- to: 17,
5227
- from: 17,
5228
- serialize: (x) => "\\x" + Buffer.from(x).toString("hex"),
5229
- parse: (x) => Buffer.from(x.slice(2), "hex")
5230
- }
5231
- };
5232
- var NotTagged = class {
5233
- then() {
5234
- notTagged();
5235
- }
5236
- catch() {
5237
- notTagged();
5238
- }
5239
- finally() {
5240
- notTagged();
5241
- }
5242
- };
5243
- var Identifier = class extends NotTagged {
5244
- constructor(value) {
5245
- super();
5246
- this.value = escapeIdentifier(value);
5247
- }
5248
- };
5249
- var Parameter = class extends NotTagged {
5250
- constructor(value, type, array) {
5251
- super();
5252
- this.value = value;
5253
- this.type = type;
5254
- this.array = array;
5255
- }
5256
- };
5257
- var Builder = class extends NotTagged {
5258
- constructor(first, rest2) {
5259
- super();
5260
- this.first = first;
5261
- this.rest = rest2;
5262
- }
5263
- build(before, parameters, types2, options) {
5264
- const keyword = builders.map(([x, fn]) => ({ fn, i: before.search(x) })).sort((a, b2) => a.i - b2.i).pop();
5265
- return keyword.i === -1 ? escapeIdentifiers(this.first, options) : keyword.fn(this.first, this.rest, parameters, types2, options);
5266
- }
5267
- };
5268
- function handleValue(x, parameters, types2, options) {
5269
- let value = x instanceof Parameter ? x.value : x;
5270
- if (value === void 0) {
5271
- x instanceof Parameter ? x.value = options.transform.undefined : value = x = options.transform.undefined;
5272
- if (value === void 0)
5273
- throw Errors.generic("UNDEFINED_VALUE", "Undefined values are not allowed");
5274
- }
5275
- return "$" + types2.push(
5276
- x instanceof Parameter ? (parameters.push(x.value), x.array ? x.array[x.type || inferType(x.value)] || x.type || firstIsString(x.value) : x.type) : (parameters.push(x), inferType(x))
5277
- );
5278
- }
5279
- var defaultHandlers = typeHandlers(types);
5280
- function stringify(q, string, value, parameters, types2, options) {
5281
- for (let i = 1; i < q.strings.length; i++) {
5282
- string += stringifyValue(string, value, parameters, types2, options) + q.strings[i];
5283
- value = q.args[i];
5284
- }
5285
- return string;
5286
- }
5287
- function stringifyValue(string, value, parameters, types2, o) {
5288
- return value instanceof Builder ? value.build(string, parameters, types2, o) : value instanceof Query ? fragment(value, parameters, types2, o) : value instanceof Identifier ? value.value : value && value[0] instanceof Query ? value.reduce((acc, x) => acc + " " + fragment(x, parameters, types2, o), "") : handleValue(value, parameters, types2, o);
5289
- }
5290
- function fragment(q, parameters, types2, options) {
5291
- q.fragment = true;
5292
- return stringify(q, q.strings[0], q.args[0], parameters, types2, options);
5293
- }
5294
- function valuesBuilder(first, parameters, types2, columns, options) {
5295
- return first.map(
5296
- (row2) => "(" + columns.map(
5297
- (column) => stringifyValue("values", row2[column], parameters, types2, options)
5298
- ).join(",") + ")"
5299
- ).join(",");
5300
- }
5301
- function values(first, rest2, parameters, types2, options) {
5302
- const multi = Array.isArray(first[0]);
5303
- const columns = rest2.length ? rest2.flat() : Object.keys(multi ? first[0] : first);
5304
- return valuesBuilder(multi ? first : [first], parameters, types2, columns, options);
5305
- }
5306
- function select(first, rest2, parameters, types2, options) {
5307
- typeof first === "string" && (first = [first].concat(rest2));
5308
- if (Array.isArray(first))
5309
- return escapeIdentifiers(first, options);
5310
- let value;
5311
- const columns = rest2.length ? rest2.flat() : Object.keys(first);
5312
- return columns.map((x) => {
5313
- value = first[x];
5314
- return (value instanceof Query ? fragment(value, parameters, types2, options) : value instanceof Identifier ? value.value : handleValue(value, parameters, types2, options)) + " as " + escapeIdentifier(options.transform.column.to ? options.transform.column.to(x) : x);
5315
- }).join(",");
5087
+ function select(first, rest2, parameters, types2, options) {
5088
+ typeof first === "string" && (first = [first].concat(rest2));
5089
+ if (Array.isArray(first))
5090
+ return escapeIdentifiers(first, options);
5091
+ let value;
5092
+ const columns = rest2.length ? rest2.flat() : Object.keys(first);
5093
+ return columns.map((x) => {
5094
+ value = first[x];
5095
+ return (value instanceof Query ? fragment(value, parameters, types2, options) : value instanceof Identifier ? value.value : handleValue(value, parameters, types2, options)) + " as " + escapeIdentifier(options.transform.column.to ? options.transform.column.to(x) : x);
5096
+ }).join(",");
5316
5097
  }
5317
5098
  var builders = Object.entries({
5318
5099
  values,
@@ -5813,7 +5594,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
5813
5594
  function drain() {
5814
5595
  !query && onopen(connection2);
5815
5596
  }
5816
- function data4(x) {
5597
+ function data2(x) {
5817
5598
  if (incomings) {
5818
5599
  incomings.push(x);
5819
5600
  remaining -= x.length;
@@ -5867,7 +5648,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
5867
5648
  statementId = Math.random().toString(36).slice(2);
5868
5649
  statementCount = 1;
5869
5650
  lifeTimer.start();
5870
- socket.on("data", data4);
5651
+ socket.on("data", data2);
5871
5652
  keep_alive && socket.setKeepAlive && socket.setKeepAlive(true, 1e3 * keep_alive);
5872
5653
  const s = StartupMessage();
5873
5654
  write(s);
@@ -5910,7 +5691,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
5910
5691
  error(Errors.connection("CONNECTION_DESTROYED", options));
5911
5692
  clearImmediate(nextWriteTimer);
5912
5693
  if (socket) {
5913
- socket.removeListener("data", data4);
5694
+ socket.removeListener("data", data2);
5914
5695
  socket.removeListener("connect", connected);
5915
5696
  socket.readyState === "open" && socket.end(bytes_default().X().end());
5916
5697
  }
@@ -5921,7 +5702,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
5921
5702
  remaining = 0;
5922
5703
  incomings = null;
5923
5704
  clearImmediate(nextWriteTimer);
5924
- socket.removeListener("data", data4);
5705
+ socket.removeListener("data", data2);
5925
5706
  socket.removeListener("connect", connected);
5926
5707
  idleTimer.cancel();
5927
5708
  lifeTimer.cancel();
@@ -6513,14 +6294,14 @@ function Subscribe(postgres2, options) {
6513
6294
  const state2 = {
6514
6295
  lsn: Buffer.concat(x.consistent_point.split("/").map((x2) => Buffer.from(("00000000" + x2).slice(-8), "hex")))
6515
6296
  };
6516
- stream2.on("data", data4);
6297
+ stream2.on("data", data2);
6517
6298
  stream2.on("error", error);
6518
6299
  stream2.on("close", sql2.close);
6519
6300
  return { stream: stream2, state: xs.state };
6520
6301
  function error(e) {
6521
6302
  console.error("Unexpected error during logical streaming - reconnecting", e);
6522
6303
  }
6523
- function data4(x2) {
6304
+ function data2(x2) {
6524
6305
  if (x2[0] === 119) {
6525
6306
  parse(x2.subarray(25), state2, sql2.options.parsers, handle, options.transform);
6526
6307
  } else if (x2[0] === 107 && x2[17]) {
@@ -6695,9 +6476,9 @@ function largeObject(sql, oid, mode = 131072 | 262144) {
6695
6476
  async read(size2) {
6696
6477
  const l = size2 > max ? size2 - max : size2;
6697
6478
  max -= size2;
6698
- const [{ data: data4 }] = await lo.read(l);
6699
- this.push(data4);
6700
- if (data4.length < size2)
6479
+ const [{ data: data2 }] = await lo.read(l);
6480
+ this.push(data2);
6481
+ if (data2.length < size2)
6701
6482
  this.push(null);
6702
6483
  }
6703
6484
  });
@@ -7006,119 +6787,416 @@ function Postgres(a, b2) {
7006
6787
  queries.length && connect(c, queries.shift());
7007
6788
  }
7008
6789
  }
7009
- function parseOptions(a, b2) {
7010
- if (a && a.shared)
7011
- return a;
7012
- const env = process.env, o = (!a || typeof a === "string" ? b2 : a) || {}, { url, multihost } = parseUrl(a), query = [...url.searchParams].reduce((a2, [b3, c]) => (a2[b3] = c, a2), {}), host = o.hostname || o.host || multihost || url.hostname || env.PGHOST || "localhost", port = o.port || url.port || env.PGPORT || 5432, user = o.user || o.username || url.username || env.PGUSERNAME || env.PGUSER || osUsername();
7013
- o.no_prepare && (o.prepare = false);
7014
- query.sslmode && (query.ssl = query.sslmode, delete query.sslmode);
7015
- "timeout" in o && (console.log("The timeout option is deprecated, use idle_timeout instead"), o.idle_timeout = o.timeout);
7016
- query.sslrootcert === "system" && (query.ssl = "verify-full");
7017
- const ints = ["idle_timeout", "connect_timeout", "max_lifetime", "max_pipeline", "backoff", "keep_alive"];
7018
- const defaults = {
7019
- max: globalThis.Cloudflare ? 3 : 10,
7020
- ssl: false,
7021
- sslnegotiation: null,
7022
- idle_timeout: null,
7023
- connect_timeout: 30,
7024
- max_lifetime,
7025
- max_pipeline: 100,
7026
- backoff,
7027
- keep_alive: 60,
7028
- prepare: true,
7029
- debug: false,
7030
- fetch_types: true,
7031
- publications: "alltables",
7032
- target_session_attrs: null
7033
- };
7034
- return {
7035
- host: Array.isArray(host) ? host : host.split(",").map((x) => x.split(":")[0]),
7036
- port: Array.isArray(port) ? port : host.split(",").map((x) => parseInt(x.split(":")[1] || port)),
7037
- path: o.path || host.indexOf("/") > -1 && host + "/.s.PGSQL." + port,
7038
- database: o.database || o.db || (url.pathname || "").slice(1) || env.PGDATABASE || user,
7039
- user,
7040
- pass: o.pass || o.password || url.password || env.PGPASSWORD || "",
7041
- ...Object.entries(defaults).reduce(
7042
- (acc, [k, d]) => {
7043
- const value = k in o ? o[k] : k in query ? query[k] === "disable" || query[k] === "false" ? false : query[k] : env["PG" + k.toUpperCase()] || d;
7044
- acc[k] = typeof value === "string" && ints.includes(k) ? +value : value;
7045
- return acc;
7046
- },
7047
- {}
7048
- ),
7049
- connection: {
7050
- application_name: env.PGAPPNAME || "postgres.js",
7051
- ...o.connection,
7052
- ...Object.entries(query).reduce((acc, [k, v]) => (k in defaults || (acc[k] = v), acc), {})
7053
- },
7054
- types: o.types || {},
7055
- target_session_attrs: tsa(o, url, env),
7056
- onnotice: o.onnotice,
7057
- onnotify: o.onnotify,
7058
- onclose: o.onclose,
7059
- onparameter: o.onparameter,
7060
- socket: o.socket,
7061
- transform: parseTransform(o.transform || { undefined: void 0 }),
7062
- parameters: {},
7063
- shared: { retries: 0, typeArrayMap: {} },
7064
- ...mergeUserTypes(o.types)
7065
- };
6790
+ function parseOptions(a, b2) {
6791
+ if (a && a.shared)
6792
+ return a;
6793
+ const env = process.env, o = (!a || typeof a === "string" ? b2 : a) || {}, { url, multihost } = parseUrl(a), query = [...url.searchParams].reduce((a2, [b3, c]) => (a2[b3] = c, a2), {}), host = o.hostname || o.host || multihost || url.hostname || env.PGHOST || "localhost", port = o.port || url.port || env.PGPORT || 5432, user = o.user || o.username || url.username || env.PGUSERNAME || env.PGUSER || osUsername();
6794
+ o.no_prepare && (o.prepare = false);
6795
+ query.sslmode && (query.ssl = query.sslmode, delete query.sslmode);
6796
+ "timeout" in o && (console.log("The timeout option is deprecated, use idle_timeout instead"), o.idle_timeout = o.timeout);
6797
+ query.sslrootcert === "system" && (query.ssl = "verify-full");
6798
+ const ints = ["idle_timeout", "connect_timeout", "max_lifetime", "max_pipeline", "backoff", "keep_alive"];
6799
+ const defaults = {
6800
+ max: globalThis.Cloudflare ? 3 : 10,
6801
+ ssl: false,
6802
+ sslnegotiation: null,
6803
+ idle_timeout: null,
6804
+ connect_timeout: 30,
6805
+ max_lifetime,
6806
+ max_pipeline: 100,
6807
+ backoff,
6808
+ keep_alive: 60,
6809
+ prepare: true,
6810
+ debug: false,
6811
+ fetch_types: true,
6812
+ publications: "alltables",
6813
+ target_session_attrs: null
6814
+ };
6815
+ return {
6816
+ host: Array.isArray(host) ? host : host.split(",").map((x) => x.split(":")[0]),
6817
+ port: Array.isArray(port) ? port : host.split(",").map((x) => parseInt(x.split(":")[1] || port)),
6818
+ path: o.path || host.indexOf("/") > -1 && host + "/.s.PGSQL." + port,
6819
+ database: o.database || o.db || (url.pathname || "").slice(1) || env.PGDATABASE || user,
6820
+ user,
6821
+ pass: o.pass || o.password || url.password || env.PGPASSWORD || "",
6822
+ ...Object.entries(defaults).reduce(
6823
+ (acc, [k, d]) => {
6824
+ const value = k in o ? o[k] : k in query ? query[k] === "disable" || query[k] === "false" ? false : query[k] : env["PG" + k.toUpperCase()] || d;
6825
+ acc[k] = typeof value === "string" && ints.includes(k) ? +value : value;
6826
+ return acc;
6827
+ },
6828
+ {}
6829
+ ),
6830
+ connection: {
6831
+ application_name: env.PGAPPNAME || "postgres.js",
6832
+ ...o.connection,
6833
+ ...Object.entries(query).reduce((acc, [k, v]) => (k in defaults || (acc[k] = v), acc), {})
6834
+ },
6835
+ types: o.types || {},
6836
+ target_session_attrs: tsa(o, url, env),
6837
+ onnotice: o.onnotice,
6838
+ onnotify: o.onnotify,
6839
+ onclose: o.onclose,
6840
+ onparameter: o.onparameter,
6841
+ socket: o.socket,
6842
+ transform: parseTransform(o.transform || { undefined: void 0 }),
6843
+ parameters: {},
6844
+ shared: { retries: 0, typeArrayMap: {} },
6845
+ ...mergeUserTypes(o.types)
6846
+ };
6847
+ }
6848
+ function tsa(o, url, env) {
6849
+ const x = o.target_session_attrs || url.searchParams.get("target_session_attrs") || env.PGTARGETSESSIONATTRS;
6850
+ if (!x || ["read-write", "read-only", "primary", "standby", "prefer-standby"].includes(x))
6851
+ return x;
6852
+ throw new Error("target_session_attrs " + x + " is not supported");
6853
+ }
6854
+ function backoff(retries) {
6855
+ return (0.5 + Math.random() / 2) * Math.min(3 ** retries / 100, 20);
6856
+ }
6857
+ function max_lifetime() {
6858
+ return 60 * (30 + Math.random() * 30);
6859
+ }
6860
+ function parseTransform(x) {
6861
+ return {
6862
+ undefined: x.undefined,
6863
+ column: {
6864
+ from: typeof x.column === "function" ? x.column : x.column && x.column.from,
6865
+ to: x.column && x.column.to
6866
+ },
6867
+ value: {
6868
+ from: typeof x.value === "function" ? x.value : x.value && x.value.from,
6869
+ to: x.value && x.value.to
6870
+ },
6871
+ row: {
6872
+ from: typeof x.row === "function" ? x.row : x.row && x.row.from,
6873
+ to: x.row && x.row.to
6874
+ }
6875
+ };
6876
+ }
6877
+ function parseUrl(url) {
6878
+ if (!url || typeof url !== "string")
6879
+ return { url: { searchParams: /* @__PURE__ */ new Map() } };
6880
+ let host = url;
6881
+ host = host.slice(host.indexOf("://") + 3).split(/[?/]/)[0];
6882
+ host = decodeURIComponent(host.slice(host.indexOf("@") + 1));
6883
+ const urlObj = new URL(url.replace(host, host.split(",")[0]));
6884
+ return {
6885
+ url: {
6886
+ username: decodeURIComponent(urlObj.username),
6887
+ password: decodeURIComponent(urlObj.password),
6888
+ host: urlObj.host,
6889
+ hostname: urlObj.hostname,
6890
+ port: urlObj.port,
6891
+ pathname: urlObj.pathname,
6892
+ searchParams: urlObj.searchParams
6893
+ },
6894
+ multihost: host.indexOf(",") > -1 && host
6895
+ };
6896
+ }
6897
+ function osUsername() {
6898
+ try {
6899
+ return os.userInfo().username;
6900
+ } catch (_) {
6901
+ return process.env.USERNAME || process.env.USER || process.env.LOGNAME;
6902
+ }
6903
+ }
6904
+
6905
+ // ../types/src/ulid.ts
6906
+ var ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
6907
+ function ulid(now = Date.now()) {
6908
+ let time = "";
6909
+ let t = now;
6910
+ for (let i = 0; i < 10; i++) {
6911
+ time = ALPHABET[t % 32] + time;
6912
+ t = Math.floor(t / 32);
6913
+ }
6914
+ const rand = new Uint8Array(16);
6915
+ crypto.getRandomValues(rand);
6916
+ let out = time;
6917
+ for (let i = 0; i < 16; i++) {
6918
+ out += ALPHABET[rand[i] % 32];
6919
+ }
6920
+ return out;
6921
+ }
6922
+ function newId(prefix) {
6923
+ return `${prefix}_${ulid()}`;
6924
+ }
6925
+
6926
+ // ../types/src/index.ts
6927
+ var KNOWN_SCOPES = [
6928
+ "features:read",
6929
+ "features:write",
6930
+ // Dedicated write scope for the BYO-secret path. PUT /v1/secrets/:feature/:name
6931
+ // requires THIS (or 'admin') as of 2026-07-17 (audit H2 — 'features:write' is no
6932
+ // longer secret-write-equivalent). GET/DELETE /v1/secrets still also accept
6933
+ // 'features:write'.
6934
+ "secrets:write",
6935
+ "users:read",
6936
+ "users:write",
6937
+ "notifications:send",
6938
+ "notifications:read",
6939
+ "jobs:read",
6940
+ "jobs:write",
6941
+ "auth:read",
6942
+ "auth:write",
6943
+ // Narrow least-privilege scope for the SIGN-IN surface only (2026-09-10,
6944
+ // F4-25/F8-56): every auth route an anonymous or self-authenticating client
6945
+ // must call to obtain, renew, verify or end ITS OWN session (sign-up/sign-in,
6946
+ // magic-link, OTP, anonymous, step-up, OAuth, refresh, by-token revoke,
6947
+ // sessions/verify, password reset). It does NOT reach the administrative
6948
+ // routes — GET /v1/auth/sessions (auth:read), POST /v1/auth/sessions/:id/
6949
+ // revoke and POST /v1/auth/users/:id/erase (auth:write). This is the scope a
6950
+ // key baked into a browser/mobile bundle carries for sign-in; 'auth:write'
6951
+ // still satisfies every route (any-of, the usage:read idiom) so existing keys
6952
+ // keep working.
6953
+ "auth:signin",
6954
+ "ratelimits:read",
6955
+ "ratelimits:write",
6956
+ "ratelimits:check",
6957
+ "files:read",
6958
+ "files:write",
6959
+ "webhooks:read",
6960
+ "webhooks:write",
6961
+ "comments:read",
6962
+ "comments:write",
6963
+ "cms:read",
6964
+ "cms:write",
6965
+ "realtime:read",
6966
+ "realtime:write",
6967
+ "presence:read",
6968
+ "orgs:read",
6969
+ "orgs:write",
6970
+ "payments:read",
6971
+ "payments:write",
6972
+ "vector-search:read",
6973
+ "vector-search:write",
6974
+ "ai:read",
6975
+ "ai:write",
6976
+ "rag:read",
6977
+ "rag:write",
6978
+ "activity-feed:read",
6979
+ "activity-feed:write",
6980
+ "functions:invoke",
6981
+ "functions:read",
6982
+ "functions:write",
6983
+ "copilot:read",
6984
+ "copilot:write",
6985
+ // Narrow least-privilege read for GET /v1/usage (2026-08-05, parity P0#4).
6986
+ // The route ALSO accepts 'features:read' (any-of, the secrets:write idiom)
6987
+ // so pre-existing keys keep working; mint this when a key should see usage
6988
+ // and nothing else.
6989
+ "usage:read",
6990
+ "admin"
6991
+ ];
6992
+ var FUNCTION_SCOPE_AUDIENCE = {
6993
+ cms: "cms",
6994
+ payments: "payments",
6995
+ notifications: "notifications",
6996
+ comments: "comments",
6997
+ files: "files",
6998
+ ai: "ai",
6999
+ rag: "rag",
7000
+ "vector-search": "vector-search",
7001
+ "activity-feed": "activity-feed",
7002
+ orgs: "orgs",
7003
+ auth: "auth",
7004
+ jobs: "jobs",
7005
+ realtime: "realtime",
7006
+ presence: "realtime",
7007
+ // presence:read is served by the realtime worker
7008
+ ratelimits: "rate-limits",
7009
+ // the worker checks `ratelimits:*`; its audience is 'rate-limits'
7010
+ // The end-user REGISTRY (/v1/users*) and the usage meter (/v1/usage) are
7011
+ // control-plane routes. The edge already re-mints a function's bearer for
7012
+ // aud='control-plane' exactly as it does for a feature worker, and the
7013
+ // control plane verifies that audience + scope-checks per route — so this
7014
+ // is a contained audience. The minted claim is CONFINED to
7015
+ // CONTROL_PLANE_FUNCTION_SCOPES (scopesForFunctionAudience) so the same token
7016
+ // can never satisfy any other scope-gated control-plane route.
7017
+ users: "control-plane",
7018
+ usage: "control-plane"
7019
+ };
7020
+ var FUNCTION_SCOPE_PREFIX_ALIASES = {
7021
+ "rate-limits": "ratelimits",
7022
+ search: "vector-search",
7023
+ vector: "vector-search",
7024
+ feeds: "activity-feed"
7025
+ };
7026
+ var CONTROL_PLANE_FUNCTION_SCOPES = ["users:read", "users:write", "usage:read"];
7027
+ function canonicalFunctionScope(scope) {
7028
+ const i = scope.indexOf(":");
7029
+ if (i <= 0) return scope;
7030
+ const prefix = scope.slice(0, i);
7031
+ const canonical2 = FUNCTION_SCOPE_PREFIX_ALIASES[prefix];
7032
+ return canonical2 ? `${canonical2}${scope.slice(i)}` : scope;
7033
+ }
7034
+ function functionScopeAudience(scope) {
7035
+ const c = canonicalFunctionScope(scope);
7036
+ const i = c.indexOf(":");
7037
+ if (i <= 0) return null;
7038
+ return FUNCTION_SCOPE_AUDIENCE[c.slice(0, i)] ?? null;
7066
7039
  }
7067
- function tsa(o, url, env) {
7068
- const x = o.target_session_attrs || url.searchParams.get("target_session_attrs") || env.PGTARGETSESSIONATTRS;
7069
- if (!x || ["read-write", "read-only", "primary", "standby", "prefer-standby"].includes(x))
7070
- return x;
7071
- throw new Error("target_session_attrs " + x + " is not supported");
7040
+ function functionCallbackAudiences(scopes) {
7041
+ const out = [];
7042
+ for (const s of scopes) {
7043
+ const a = functionScopeAudience(s);
7044
+ if (a && !out.includes(a)) out.push(a);
7045
+ }
7046
+ return out;
7072
7047
  }
7073
- function backoff(retries) {
7074
- return (0.5 + Math.random() / 2) * Math.min(3 ** retries / 100, 20);
7048
+ function scopesForFunctionAudience(aud, scopes) {
7049
+ const canon = [...new Set(scopes.map(canonicalFunctionScope))];
7050
+ if (aud === "control-plane") return canon.filter((s) => CONTROL_PLANE_FUNCTION_SCOPES.includes(s));
7051
+ return canon;
7075
7052
  }
7076
- function max_lifetime() {
7077
- return 60 * (30 + Math.random() * 30);
7053
+ var TENANT_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
7054
+ var FUNCTION_SOURCE_MAX_BYTES = 512e3;
7055
+
7056
+ // ../runtime/src/hash.ts
7057
+ async function sha256Hex(input) {
7058
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
7059
+ return [...new Uint8Array(digest)].map((b2) => b2.toString(16).padStart(2, "0")).join("");
7078
7060
  }
7079
- function parseTransform(x) {
7080
- return {
7081
- undefined: x.undefined,
7082
- column: {
7083
- from: typeof x.column === "function" ? x.column : x.column && x.column.from,
7084
- to: x.column && x.column.to
7085
- },
7086
- value: {
7087
- from: typeof x.value === "function" ? x.value : x.value && x.value.from,
7088
- to: x.value && x.value.to
7089
- },
7090
- row: {
7091
- from: typeof x.row === "function" ? x.row : x.row && x.row.from,
7092
- to: x.row && x.row.to
7093
- }
7094
- };
7061
+ async function hmacSha256Hex(secret, data2) {
7062
+ const key = await crypto.subtle.importKey(
7063
+ "raw",
7064
+ new TextEncoder().encode(secret),
7065
+ { name: "HMAC", hash: "SHA-256" },
7066
+ false,
7067
+ ["sign"]
7068
+ );
7069
+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data2));
7070
+ return [...new Uint8Array(sig)].map((b2) => b2.toString(16).padStart(2, "0")).join("");
7095
7071
  }
7096
- function parseUrl(url) {
7097
- if (!url || typeof url !== "string")
7098
- return { url: { searchParams: /* @__PURE__ */ new Map() } };
7099
- let host = url;
7100
- host = host.slice(host.indexOf("://") + 3).split(/[?/]/)[0];
7101
- host = decodeURIComponent(host.slice(host.indexOf("@") + 1));
7102
- const urlObj = new URL(url.replace(host, host.split(",")[0]));
7103
- return {
7104
- url: {
7105
- username: decodeURIComponent(urlObj.username),
7106
- password: decodeURIComponent(urlObj.password),
7107
- host: urlObj.host,
7108
- hostname: urlObj.hostname,
7109
- port: urlObj.port,
7110
- pathname: urlObj.pathname,
7111
- searchParams: urlObj.searchParams
7112
- },
7113
- multihost: host.indexOf(",") > -1 && host
7072
+ var AI_TEMPLATE_SCHEMA_MAX_DEPTH = 64;
7073
+ async function aiTemplateContentSha256(t) {
7074
+ const canon = (v, depth) => {
7075
+ if (v === null || typeof v !== "object") return v;
7076
+ if (depth > AI_TEMPLATE_SCHEMA_MAX_DEPTH) throw new RangeError(`schema nests deeper than ${AI_TEMPLATE_SCHEMA_MAX_DEPTH} levels`);
7077
+ if (Array.isArray(v)) return v.map((x) => canon(x, depth + 1));
7078
+ const o = v;
7079
+ return Object.fromEntries(Object.keys(o).sort().map((k) => [k, canon(o[k], depth + 1)]));
7114
7080
  };
7081
+ return sha256Hex(JSON.stringify({
7082
+ schema: canon(t.schema ?? null, 1),
7083
+ system: t.system ?? null,
7084
+ user: t.user
7085
+ }));
7115
7086
  }
7116
- function osUsername() {
7087
+
7088
+ // ../runtime/src/egress.ts
7089
+ function isPrivateIPv4(host) {
7090
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
7091
+ if (!m) return false;
7092
+ const o = m.slice(1).map(Number);
7093
+ if (o.some((n) => n > 255)) return false;
7094
+ const [a, b2] = o;
7095
+ return a === 10 || // 10.0.0.0/8 RFC1918
7096
+ a === 172 && b2 >= 16 && b2 <= 31 || // 172.16.0.0/12 RFC1918
7097
+ a === 192 && b2 === 168 || // 192.168.0.0/16 RFC1918
7098
+ a === 127 || // 127.0.0.0/8 loopback
7099
+ a === 169 && b2 === 254 || // 169.254.0.0/16 link-local (incl. metadata)
7100
+ a === 100 && b2 >= 64 && b2 <= 127 || // 100.64.0.0/10 CGNAT / shared
7101
+ a === 0 || // 0.0.0.0/8 "this host"
7102
+ a >= 224;
7103
+ }
7104
+ function expandIPv6(host) {
7105
+ if (!/^[0-9a-f:.]+$/.test(host) || !host.includes(":")) return null;
7106
+ let h = host;
7107
+ let tail = [];
7108
+ const lastColon = h.lastIndexOf(":");
7109
+ const maybeV4 = h.slice(lastColon + 1);
7110
+ if (maybeV4.includes(".")) {
7111
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(maybeV4);
7112
+ if (!m) return null;
7113
+ const q = m.slice(1).map(Number);
7114
+ if (q.some((n) => n > 255)) return null;
7115
+ tail = [q[0] << 8 | q[1], q[2] << 8 | q[3]];
7116
+ h = h.slice(0, lastColon + 1) + "0:0";
7117
+ }
7118
+ const parts = h.split("::");
7119
+ if (parts.length > 2) return null;
7120
+ const head = parts[0] ? parts[0].split(":") : [];
7121
+ const rest2 = parts.length === 2 ? parts[1] ? parts[1].split(":") : [] : null;
7122
+ let groups;
7123
+ if (rest2 === null) {
7124
+ groups = head;
7125
+ } else {
7126
+ const fill = 8 - head.length - rest2.length - tail.length;
7127
+ if (fill < 0) return null;
7128
+ groups = [...head, ...Array(fill).fill("0"), ...rest2];
7129
+ }
7130
+ const nums = groups.map((g) => g === "" ? 0 : parseInt(g, 16));
7131
+ const all = [...nums, ...tail];
7132
+ if (all.length !== 8 || all.some((n) => !Number.isFinite(n) || n < 0 || n > 65535)) {
7133
+ return null;
7134
+ }
7135
+ return all.map((n) => n.toString(16));
7136
+ }
7137
+ function isPrivateIPv6(host) {
7138
+ const hextets = expandIPv6(host);
7139
+ if (!hextets) return false;
7140
+ const n = hextets.map((x) => parseInt(x, 16));
7141
+ const [h0, h1] = n;
7142
+ if (n.every((x) => x === 0)) return true;
7143
+ if (n.slice(0, 7).every((x) => x === 0) && n[7] === 1) return true;
7144
+ if ((h0 & 65024) === 64512) return true;
7145
+ if ((h0 & 65472) === 65152) return true;
7146
+ if (n.slice(0, 5).every((x) => x === 0) && h0 === 0 && n[5] === 65535) {
7147
+ const a = n[6] >> 8 & 255;
7148
+ const b2 = n[6] & 255;
7149
+ const c = n[7] >> 8 & 255;
7150
+ const d = n[7] & 255;
7151
+ return isPrivateIPv4(`${a}.${b2}.${c}.${d}`);
7152
+ }
7153
+ if (h0 === 100 && h1 === 65435) {
7154
+ const a = n[6] >> 8 & 255;
7155
+ const b2 = n[6] & 255;
7156
+ const c = n[7] >> 8 & 255;
7157
+ const d = n[7] & 255;
7158
+ return isPrivateIPv4(`${a}.${b2}.${c}.${d}`);
7159
+ }
7160
+ return false;
7161
+ }
7162
+ var VXIL_API_HOSTS = /* @__PURE__ */ new Set(["api.vxil.com", "api.vxil.org"]);
7163
+ function egressDecision(targetUrl, allowHosts = []) {
7164
+ let u;
7117
7165
  try {
7118
- return os.userInfo().username;
7119
- } catch (_) {
7120
- return process.env.USERNAME || process.env.USER || process.env.LOGNAME;
7166
+ u = new URL(targetUrl);
7167
+ } catch {
7168
+ return { allow: false, reason: "invalid-url" };
7169
+ }
7170
+ if (u.protocol !== "https:") return { allow: false, reason: `non-https:${u.protocol}` };
7171
+ if (u.username !== "" || u.password !== "") return { allow: false, reason: "embedded-credentials" };
7172
+ const host = u.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "");
7173
+ if (host === "") return { allow: false, reason: "empty-host" };
7174
+ if (isPrivateIPv4(host) || isPrivateIPv6(host)) return { allow: false, reason: `private-ip:${host}` };
7175
+ if (host === "localhost" || host.endsWith(".localhost") || host === "metadata.google.internal" || host.endsWith(".internal") || host.endsWith(".local")) {
7176
+ return { allow: false, reason: `internal-host:${host}` };
7177
+ }
7178
+ if (host.endsWith(".workers.dev")) return { allow: false, reason: `sibling-worker:${host}` };
7179
+ if (VXIL_API_HOSTS.has(host)) return { allow: true, reason: "vxil-api" };
7180
+ for (const a of allowHosts) {
7181
+ const h = a.toLowerCase().trim();
7182
+ if (h && (host === h || host.endsWith("." + h))) return { allow: true, reason: `allowlist:${h}` };
7121
7183
  }
7184
+ return { allow: false, reason: `not-allowlisted:${host}` };
7185
+ }
7186
+
7187
+ // ../runtime/src/jobsCallbackSig.ts
7188
+ var JOBS_SIG_HEADER = "X-Vxil-Jobs-Signature";
7189
+ function signedString(runId, tenantId, issuedAt, bodyHashHex) {
7190
+ return `${runId}.${tenantId}.${issuedAt}.${bodyHashHex}`;
7191
+ }
7192
+ async function signJobsCallbackWithTenantSecret(tenantSecret, params) {
7193
+ const t = params.issuedAt ?? Math.floor(Date.now() / 1e3);
7194
+ const bodyHash = await sha256Hex(params.body);
7195
+ const v1 = await hmacSha256Hex(
7196
+ tenantSecret,
7197
+ signedString(params.runId, params.tenantId, t, bodyHash)
7198
+ );
7199
+ return `t=${t},v1=${v1}`;
7122
7200
  }
7123
7201
 
7124
7202
  // src/apiState.ts
@@ -7347,14 +7425,14 @@ async function readOnlyPlan(api, block, allowDestructive) {
7347
7425
  }
7348
7426
  const unavailable = readUnavailable(`GET ${route}`, res);
7349
7427
  if (unavailable) return { ...out, remoteUnavailable: unavailable };
7350
- const data4 = classifyRead(res.status, res.body.error?.code) === "ok" ? res.body.data ?? {} : {};
7428
+ const data2 = classifyRead(res.status, res.body.error?.code) === "ok" ? res.body.data ?? {} : {};
7351
7429
  const s = out.summary;
7352
7430
  const destructive = (recreate, undeclared) => {
7353
7431
  (allowDestructive ? s.recreated : s.pending_destructive).push(...recreate);
7354
7432
  (allowDestructive ? s.deleted : s.undeclared).push(...undeclared);
7355
7433
  };
7356
7434
  if (datum === "policies") {
7357
- const rows = listOf(data4.policies);
7435
+ const rows = listOf(data2.policies);
7358
7436
  const capped = rlPolicyListCapError(rows.length);
7359
7437
  if (capped) return { ...out, refused: `rate-limits: ${capped}` };
7360
7438
  const plan = planRlPolicies(manifest.policies, rows);
@@ -7364,16 +7442,17 @@ async function readOnlyPlan(api, block, allowDestructive) {
7364
7442
  s.ambiguous.push(...plan.ambiguous.map((a) => a.name));
7365
7443
  destructive(plan.recreate.map((r) => r.name), plan.undeclared.map((u) => u.name));
7366
7444
  } else if (datum === "subscriptions") {
7367
- const plan = planWebhookSubscriptions(manifest.subscriptions, listOf(data4.subscriptions));
7445
+ const plan = planWebhookSubscriptions(manifest.subscriptions, listOf(data2.subscriptions));
7368
7446
  s.created.push(...plan.create.map((c) => c.target_url));
7447
+ s.updated.push(...plan.update.map((u) => u.target_url));
7369
7448
  s.unchanged.push(...plan.unchanged);
7370
- destructive(plan.recreate.map((r) => r.target_url), plan.undeclared.map((u) => u.target_url));
7449
+ destructive([], plan.undeclared.map((u) => u.target_url));
7371
7450
  } else {
7372
7451
  const hashed = await Promise.all(manifest.templates.map(async (t) => ({
7373
7452
  template: t.template,
7374
7453
  content_sha256: await aiTemplateContentSha256({ system: t.system ?? null, user: t.user, schema: t.schema ?? null })
7375
7454
  })));
7376
- const plan = planAiTemplates(hashed, listOf(data4.templates));
7455
+ const plan = planAiTemplates(hashed, listOf(data2.templates));
7377
7456
  s.created.push(...plan.create);
7378
7457
  s.updated.push(...plan.update);
7379
7458
  s.unchanged.push(...plan.unchanged);
@@ -7435,13 +7514,13 @@ async function convergeViaApply(opts, allowDestructive, results, pending) {
7435
7514
  unavailable({ route: "POST /v1/apply", status: 0, code: "network_error", message: firstLine(e) });
7436
7515
  return;
7437
7516
  }
7438
- const data4 = res.body.data;
7439
- if (res.status < 200 || res.status >= 300 || !data4 || !Array.isArray(data4.steps)) {
7517
+ const data2 = res.body.data;
7518
+ if (res.status < 200 || res.status >= 300 || !data2 || !Array.isArray(data2.steps)) {
7440
7519
  const e = res.body.error ?? {};
7441
7520
  unavailable({ route: "POST /v1/apply", status: res.status, ...e.code ? { code: e.code } : {}, ...e.message ? { message: e.message } : {} });
7442
7521
  return;
7443
7522
  }
7444
- view = data4;
7523
+ view = data2;
7445
7524
  const stopped = view.steps.filter((st) => st.status === "pending" && st.error);
7446
7525
  const propagating = view.status === "running" && stopped.length > 0 && stopped.every((st) => st.kind === "api-state" && (st.error ?? "").startsWith("deferred:"));
7447
7526
  if (!propagating || attempt >= DEFERRED_RETRY_ATTEMPTS) break;
@@ -7702,13 +7781,14 @@ async function planFunctions({ api, functions, cwd, apply, onNote }) {
7702
7781
  throw new Error(`functions: invalid name '${name}' (must match /^[a-z][a-z0-9-]{0,47}$/)`);
7703
7782
  }
7704
7783
  const { source, bytes } = await bundleFunction(resolve2(cwd, def.entry), { projectRoot: cwd, ...onNote ? { onNote } : {} });
7705
- const triggerKind = def.trigger?.kind ?? "http";
7784
+ const bindings = lowerAllTriggerBindings(def, name);
7785
+ const triggerKind = bindings[0]?.kind ?? "http";
7706
7786
  const secrets = normalizeSecretRefs(def.secrets);
7707
- const bindings = lowerTriggerBindings(def.trigger);
7708
7787
  const limits = normalizeFnLimits(def.limits);
7709
7788
  const remoteFn = remote.get(name);
7710
7789
  const sha = sourceSha12(source);
7711
- const label = `${name} (${Math.round(bytes / 100) / 10} KB) [${triggerKind}${def.scopes?.length ? " \xB7 " + def.scopes.join(",") : ""}${secrets.length ? " \xB7 secrets:" + secrets.map((s) => s.slice("secret:".length)).join(",") : ""}${limits?.cpuMs !== void 0 ? " \xB7 cpu:" + limits.cpuMs + "ms" : ""}${limits?.timeoutMs !== void 0 ? " \xB7 egress:" + limits.timeoutMs + "ms" : ""}]`;
7790
+ const retryN = bindings.find((b2) => b2.retry)?.retry?.maxAttempts;
7791
+ const label = `${name} (${Math.round(bytes / 100) / 10} KB) [${bindings.map((b2) => b2.kind).join("+")}${retryN !== void 0 ? " \xB7 retry:" + retryN : ""}${def.scopes?.length ? " \xB7 " + def.scopes.join(",") : ""}${secrets.length ? " \xB7 secrets:" + secrets.map((s) => s.slice("secret:".length)).join(",") : ""}${limits?.cpuMs !== void 0 ? " \xB7 cpu:" + limits.cpuMs + "ms" : ""}${limits?.timeoutMs !== void 0 ? " \xB7 egress:" + limits.timeoutMs + "ms" : ""}]`;
7712
7792
  if (remoteFn !== void 0 && remoteFn.scriptRef.endsWith(`-${sha}`) && sameSecretSet(remoteFn.secrets, secrets) && sameStringSet2(clampFunctionScopes(def.scopes), remoteFn.scopes) && sameStringSet2(def.egressAllow ?? [], remoteFn.egressAllow) && stableStringify(remoteFn.bindings) === stableStringify(bindings) && stableStringify(remoteFn.signature) === stableStringify(def.signature ?? null) && stableStringify(remoteFn.limits ?? null) === stableStringify(limits ?? null)) {
7713
7793
  return { change: { name, kind: "unchanged", detail: label }, applied: 0, cmsHooks: null, webhooks: null };
7714
7794
  }
@@ -7725,10 +7805,10 @@ async function planFunctions({ api, functions, cwd, apply, onNote }) {
7725
7805
  const e = res.body.error ?? {};
7726
7806
  throw new Error(`functions: deploy ${name} failed: ${e.code ?? res.status} ${e.message ?? ""} ${e.hint ?? ""}`);
7727
7807
  }
7728
- const data4 = res.body.data;
7729
- const cmsHooks = data4?.cms_hook_subscriptions ?? null;
7730
- const webhooks = data4?.webhook_subscriptions ?? null;
7731
- const warnings2 = (data4?.warnings ?? []).map((message) => ({ fn: name, message: String(message) }));
7808
+ const data2 = res.body.data;
7809
+ const cmsHooks = data2?.cms_hook_subscriptions ?? null;
7810
+ const webhooks = data2?.webhook_subscriptions ?? null;
7811
+ const warnings2 = (data2?.warnings ?? []).map((message) => ({ fn: name, message: String(message) }));
7732
7812
  return { change, applied: 1, cmsHooks, webhooks, warnings: warnings2 };
7733
7813
  }
7734
7814
  return { change, applied: 0, cmsHooks: null, webhooks: null, warnings: [] };
@@ -8191,7 +8271,7 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8191
8271
  return { changes, applied };
8192
8272
  }
8193
8273
  async function applyDestructive(api, collections, c) {
8194
- const ok2xx = (status) => status >= 200 && status < 300;
8274
+ const ok2xx2 = (status) => status >= 200 && status < 300;
8195
8275
  if (c.op === "drop-collection") {
8196
8276
  await dropCollection(api, c.collection);
8197
8277
  return 1;
@@ -8199,7 +8279,7 @@ async function applyDestructive(api, collections, c) {
8199
8279
  const path = `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`;
8200
8280
  if (c.op === "drop-field") {
8201
8281
  const res2 = await api("POST", path, { drop_field: c.field, allow_destructive: true });
8202
- if (!ok2xx(res2.status) && res2.body.error?.code !== "not_found") {
8282
+ if (!ok2xx2(res2.status) && res2.body.error?.code !== "not_found") {
8203
8283
  const e = res2.body.error ?? {};
8204
8284
  throw new Error(`cms: drop ${c.collection}.${c.field} failed: ${e.code ?? res2.status} ${e.message ?? ""}`);
8205
8285
  }
@@ -8208,7 +8288,7 @@ async function applyDestructive(api, collections, c) {
8208
8288
  if (c.op === "vacate-slot") {
8209
8289
  const rf = c.remote;
8210
8290
  const res2 = await api("POST", path, vacateSlotBody(rf));
8211
- if (!ok2xx(res2.status) && res2.body.error?.code !== "already_exists") {
8291
+ if (!ok2xx2(res2.status) && res2.body.error?.code !== "already_exists") {
8212
8292
  const e = res2.body.error ?? {};
8213
8293
  throw new Error(`cms: vacate ${c.collection}.${c.field} slot failed: ${e.code ?? res2.status} ${e.message ?? ""}`);
8214
8294
  }
@@ -8216,7 +8296,7 @@ async function applyDestructive(api, collections, c) {
8216
8296
  }
8217
8297
  const fd = collections[c.collection].fields[c.field];
8218
8298
  const res = await api("POST", path, { ...fieldToInput(c.field, fd), allow_destructive: true });
8219
- if (!ok2xx(res.status)) {
8299
+ if (!ok2xx2(res.status)) {
8220
8300
  const e = res.body.error ?? {};
8221
8301
  throw new Error(`cms: alter ${c.collection}.${c.field} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
8222
8302
  }
@@ -8259,7 +8339,7 @@ function formatCollectionDrop(collection, plan, declaredLocally) {
8259
8339
 
8260
8340
  // src/apiCmd.ts
8261
8341
  var API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
8262
- var API_USAGE = "usage: vxil api <GET|POST|PUT|PATCH|DELETE> </v1/...> ['<json body>' | --data '<json body>']";
8342
+ var API_USAGE = "usage: vxil api <GET|POST|PUT|PATCH|DELETE> </v1/...> ['<json body>' | --data '<json body>'] (`vxil api --help` lists the operator actions)";
8263
8343
  function parseApiArgs(positionals, dataFlag) {
8264
8344
  const [rawMethod, path, ...extra] = positionals;
8265
8345
  const method = (rawMethod ?? "").toUpperCase();
@@ -8279,6 +8359,760 @@ function parseApiArgs(positionals, dataFlag) {
8279
8359
  throw new Error(`${via} must be valid JSON (got ${JSON.stringify(raw.length > 60 ? `${raw.slice(0, 57)}\u2026` : raw)})`);
8280
8360
  }
8281
8361
  }
8362
+ var API_CHEATSHEET = [
8363
+ // cms
8364
+ { feature: "cms", what: "materialize a declared read model now", cmd: "vxil api POST /v1/cms/read-models/<name>/materialize" },
8365
+ { feature: "cms", what: "publish a draft item", cmd: "vxil api POST /v1/cms/items/<collection>/<item_id>/publish" },
8366
+ { feature: "cms", what: "delete one item (soft)", cmd: "vxil api DELETE /v1/cms/items/<collection>/<item_id>" },
8367
+ { feature: "cms", what: "bulk delete by filter (dry-run first, typed confirm)", cmd: `vxil cms bulk-rm <collection> --filter '{"status":"draft"}' [--limit 25] [--dry-run] [--yes]` },
8368
+ { feature: "cms", what: "run a per-record action", cmd: "vxil api POST /v1/cms/items/<collection>/<item_id>/actions/<key>" },
8369
+ { feature: "cms", what: "re-project index slots", cmd: "vxil cms reindex <collection> [--field <field>]" },
8370
+ // users
8371
+ { feature: "users", what: "import users from a file (chunked upsert)", cmd: "vxil users import <users.json|users.csv>" },
8372
+ { feature: "users", what: "set fields on one user", cmd: `vxil api PATCH /v1/users/<id> '{"display_name":"Ada","attributes":{"locale":"fr"}}'` },
8373
+ { feature: "users", what: "remove one user (soft)", cmd: "vxil api DELETE /v1/users/<id>" },
8374
+ // notifications
8375
+ { feature: "notifications", what: "create a campaign (a draft; add schedule_cron to schedule it)", cmd: `vxil api POST /v1/notifications/campaigns '{"name":"launch","template":"transactional","audience_ref":"<ref>"}'` },
8376
+ { feature: "notifications", what: "run / pause / resume / cancel a campaign", cmd: "vxil api POST /v1/notifications/campaigns/<campaign_id>/run \xB7 POST \u2026/pause \xB7 POST \u2026/resume \xB7 POST \u2026/cancel" },
8377
+ { feature: "notifications", what: "add a suppression", cmd: `vxil api POST /v1/notifications/suppressions '{"email":"a@example.com"}'` },
8378
+ { feature: "notifications", what: "remove a suppression", cmd: "vxil api DELETE /v1/notifications/suppressions/<email>" },
8379
+ { feature: "notifications", what: "replay a dead-lettered delivery", cmd: "vxil api POST /v1/notifications/dead-letters/<delivery_id>/replay" },
8380
+ { feature: "notifications", what: "mute / unmute a user (all templates, or one template_id)", cmd: `vxil api PUT /v1/notifications/preferences '{"user_id":"<id>","muted":true}'` },
8381
+ // payments
8382
+ { feature: "payments", what: "grant a subscription by hand", cmd: `vxil api POST /v1/payments/subscriptions/grant '{"user_id":"<id>","tier":"pro","reason":"support","idempotency_key":"<key>"}'` },
8383
+ { feature: "payments", what: "revoke a manual grant", cmd: "vxil api POST /v1/payments/subscriptions/<subscription_id>/revoke" },
8384
+ { feature: "payments", what: "re-read a customer from the provider (dry_run first)", cmd: `vxil api POST /v1/payments/subscriptions/sync '{"user_id":"<id>","dry_run":true}'` },
8385
+ { feature: "payments", what: "reprocess a webhook event", cmd: "vxil api POST /v1/payments/webhook-events/<event_id>/reprocess" },
8386
+ { feature: "payments", what: "refund (through the project's own provider account)", cmd: `vxil api POST /v1/payments/refunds '{"charge_id":"<id>","reason":"requested_by_customer"}'` },
8387
+ // comments / dm
8388
+ { feature: "comments", what: "moderation delete", cmd: "vxil api DELETE /v1/comments/<comment_id>" },
8389
+ { feature: "dm", what: "block / unblock a user pair", cmd: `vxil api POST /v1/dm/blocks '{"blocker":"<user>","blocked":"<user>","blocked_state":true}'` },
8390
+ // rate-limits
8391
+ { feature: "rate-limits", what: "declare policies in vxil.config (converged by push)", cmd: 'features["rate-limits"].policies[] in vxil.config.ts \u2192 vxil push' },
8392
+ { feature: "rate-limits", what: "create a policy by hand", cmd: `vxil api POST /v1/rate-limits/policies '{"name":"login","key_template":"{tenant_id}:{ip}","limit":10,"window_seconds":60}'` },
8393
+ { feature: "rate-limits", what: "update / delete a policy (key_template is immutable: delete + recreate)", cmd: `vxil api PUT /v1/rate-limits/policies/<policy_id> '{"limit":20}' \xB7 vxil api DELETE /v1/rate-limits/policies/<policy_id>` },
8394
+ { feature: "rate-limits", what: "add / remove a per-identifier override", cmd: `vxil api POST /v1/rate-limits/policies/<policy_id>/overrides '{"pattern":"user:42","limit":1}' \xB7 vxil api DELETE /v1/rate-limits/policies/<policy_id>/overrides/<override_id>` },
8395
+ { feature: "rate-limits", what: "reset one rendered counter", cmd: `vxil api POST /v1/rate-limits/reset '{"policy_id":"<id>","key_values":{"ip":"1.2.3.4"}}'` },
8396
+ // ai / rag / search
8397
+ { feature: "ai", what: "declare templates in vxil.config (converged by push)", cmd: "features.ai.templates[] in vxil.config.ts \u2192 vxil push" },
8398
+ { feature: "ai", what: "store a template by hand", cmd: `vxil api POST /v1/ai/templates '{"template":"greet","user":"Say hello to {{name}}."}'` },
8399
+ { feature: "rag", what: "ingest a document", cmd: `vxil api POST /v1/rag/ingest/<collection> '{"doc_id":"kb1","text":"\u2026"}'` },
8400
+ { feature: "vector-search", what: "create a collection", cmd: `vxil api POST /v1/search/collections '{"collection":"docs","dimensions":1536,"embed":{"provider":"mock"}}'` },
8401
+ { feature: "vector-search", what: "run a sync tick / sweep, reset a source / purge its documents", cmd: "vxil search sync run <sourceKey> [--sweep] \xB7 vxil search sync reset <sourceKey> [--purge] [--yes]" },
8402
+ // realtime
8403
+ { feature: "realtime", what: "publish an event to a channel", cmd: `vxil api POST /v1/realtime/channels/<channel>/publish '{"event":"ping","data":{}}'` },
8404
+ // webhooks
8405
+ { feature: "webhooks", what: "inbound sources add / list / rm / test", cmd: "vxil webhooks sources add --provider stripe --name prod [--forward-url https://\u2026] \xB7 sources list \xB7 sources rm <id> [--yes] \xB7 sources test <id>" },
8406
+ { feature: "webhooks", what: "replay an inbound event / see its delivery runs", cmd: "vxil webhooks events replay <event_id> \xB7 vxil webhooks events runs <event_id>" },
8407
+ { feature: "webhooks", what: "declare outbound subscriptions in vxil.config (converged by push)", cmd: "features.webhooks.subscriptions[] in vxil.config.ts \u2192 vxil push" },
8408
+ { feature: "webhooks", what: "add an outbound subscription by hand (undeclared \u2014 `vxil diff --strict-api-state` reports it)", cmd: `vxil api POST /v1/webhooks/subscriptions '{"target_url":"https://\u2026","event_prefixes":["cms."]}'` },
8409
+ { feature: "webhooks", what: "send a test event to a subscription / delete it", cmd: "vxil api POST /v1/webhooks/subscriptions/<sub_id>/test \xB7 vxil api DELETE /v1/webhooks/subscriptions/<sub_id>" },
8410
+ // jobs
8411
+ { feature: "jobs", what: "enqueue a job", cmd: `vxil api POST /v1/jobs/enqueue '{"job_name":"resize","target_url":"https://\u2026","payload":{}}'` },
8412
+ { feature: "jobs", what: "wake runs waiting on an event", cmd: `vxil api POST /v1/jobs/events '{"event":"order.paid","payload":{}}'` },
8413
+ { feature: "jobs", what: "replay / cancel a run", cmd: "vxil api POST /v1/jobs/runs/<run_id>/replay \xB7 vxil api POST /v1/jobs/runs/<run_id>/cancel" },
8414
+ { feature: "jobs", what: "create / pause / resume / delete a schedule", cmd: `vxil api POST /v1/jobs/schedules '{"job_name":"digest","target_url":"https://\u2026","cron":"0 8 * * *"}' \xB7 POST /v1/jobs/schedules/<schedule_id>/pause \xB7 POST \u2026/resume \xB7 vxil api DELETE /v1/jobs/schedules/<schedule_id>` },
8415
+ { feature: "jobs", what: "flow rules (rate + parallelism per endpoint or job)", cmd: `vxil api POST /v1/jobs/flow-rules '{"match_kind":"job_name","match_value":"resize","max_parallel":2}' \xB7 vxil api DELETE /v1/jobs/flow-rules/<rule_id>` },
8416
+ // files
8417
+ { feature: "files", what: "upload a local file (mint \u2192 PUT the bytes \u2192 complete)", cmd: "vxil files put <path> --user <user_id> [--content-type <t>]" },
8418
+ { feature: "files", what: "delete an object", cmd: "vxil files rm <object_id> [--yes]" },
8419
+ { feature: "files", what: "extract text (OCR / PDF)", cmd: "vxil api POST /v1/files/<object_id>/extract-text '{}'" },
8420
+ { feature: "files", what: "set / clear a TTL", cmd: `vxil api PUT /v1/files/<object_id>/ttl '{"expiresInSeconds":86400}'` },
8421
+ { feature: "files", what: "share / unshare (a public download link; ttl_seconds or expires_at, max_downloads 1 = one-time)", cmd: `vxil api POST /v1/files/<object_id>/shared-links '{"ttl_seconds":3600}' \xB7 vxil api DELETE /v1/files/shared-links/<link_id>` },
8422
+ // orgs
8423
+ { feature: "orgs", what: "create / patch / delete a workspace", cmd: `vxil api POST /v1/orgs '{"slug":"acme","name":"Acme","owner_user_id":"<id>"}' \xB7 PATCH /v1/orgs/<org_id> '{"name":"\u2026"}' \xB7 DELETE /v1/orgs/<org_id>` },
8424
+ { feature: "orgs", what: "add / remove a member", cmd: `vxil api POST /v1/orgs/<org_id>/members '{"user_id":"<id>","role":"member"}' \xB7 vxil api DELETE /v1/orgs/<org_id>/members/<user_id>` },
8425
+ { feature: "orgs", what: "invite by e-mail (the token is returned once) / revoke", cmd: `vxil api POST /v1/orgs/<org_id>/invitations '{"email":"a@example.com","role":"member"}' \xB7 vxil api DELETE /v1/orgs/invitations/<invite_id>` },
8426
+ // feeds
8427
+ { feature: "activity-feed", what: "block or mute a feed for a user", cmd: `vxil api POST /v1/feeds/blocks '{"owner":"<user>","blocked":"<user>","mode":"block"}'` },
8428
+ { feature: "activity-feed", what: "remove an activity", cmd: "vxil api DELETE /v1/feeds/<group>/<feed_id>/activities/<ref>" }
8429
+ ];
8430
+ function apiCheatSheet() {
8431
+ const lines = [
8432
+ API_USAGE,
8433
+ "",
8434
+ "Calls any /v1 route with the bound project key (--dev / --target <name> pick the slot). The body is the third",
8435
+ "argument OR --data, never both; the response `data` prints as bare JSON on stdout (`| jq` works), the target",
8436
+ "banner goes to stderr; a GET prints no banner. Exit 1 on an error envelope.",
8437
+ "",
8438
+ "Operator actions from the CLI \u2014 the dashboard's per-feature actions and the line that does each one:"
8439
+ ];
8440
+ let cur = "";
8441
+ for (const l of API_CHEATSHEET) {
8442
+ if (l.feature !== cur) {
8443
+ cur = l.feature;
8444
+ lines.push("", ` ${cur}`);
8445
+ }
8446
+ lines.push(` ${l.what}`, ` ${l.cmd}`);
8447
+ }
8448
+ lines.push(
8449
+ "",
8450
+ "Declared state (rate-limit policies, outbound subscriptions, ai templates) belongs in vxil.config.ts and is",
8451
+ "converged by `vxil push`; a row added by hand is reported by `vxil diff --strict-api-state`. Session verbs",
8452
+ "(members, projects, billing, account, keys) are dedicated commands \u2014 see `vxil --help`."
8453
+ );
8454
+ return lines.join("\n");
8455
+ }
8456
+
8457
+ // src/link.ts
8458
+ function data(r) {
8459
+ return r.json.data ?? r.json;
8460
+ }
8461
+ function errText(r) {
8462
+ const e = r.json.error;
8463
+ if (!e) return `${r.status}`;
8464
+ return `${e.code ?? r.status}${e.message ? ` \u2014 ${e.message}` : ""}${e.hint ? `
8465
+ hint: ${e.hint}` : ""}`;
8466
+ }
8467
+ async function findTenantViaSession(dash, cookie, slug) {
8468
+ const list = await dash("GET", "/dashboard/tenants", void 0, cookie);
8469
+ if (list.status === 401) return { unauthorized: true };
8470
+ if (list.status !== 200) throw new Error(`could not list projects (${errText(list)})`);
8471
+ const tenants = data(list).tenants ?? [];
8472
+ const t = tenants.find((x) => x.slug === slug);
8473
+ if (!t) {
8474
+ throw new Error(
8475
+ `no project '${slug}' on this account \u2014 run \`vxil quickstart\`, or ask an admin to add you as a member`
8476
+ );
8477
+ }
8478
+ return { tenant: t };
8479
+ }
8480
+ async function linkViaSession(dash, cookie, slug, keyName) {
8481
+ const found = await findTenantViaSession(dash, cookie, slug);
8482
+ if ("unauthorized" in found) return found;
8483
+ const t = found.tenant;
8484
+ const mint = await dash("POST", `/dashboard/tenants/${t.id}/api-keys`, { name: keyName }, cookie);
8485
+ if (mint.status === 401) return { unauthorized: true };
8486
+ if (mint.status !== 201 && mint.status !== 200) {
8487
+ throw new Error(`could not mint a key for '${slug}' (${errText(mint)})`);
8488
+ }
8489
+ const apiKey = data(mint).api_key;
8490
+ if (!apiKey) throw new Error("mint returned no api_key");
8491
+ return { result: { slug, tenant_id: t.id, api_key: apiKey } };
8492
+ }
8493
+ async function linkViaDeviceFlow(dash, slug, io) {
8494
+ const now = io.now ?? Date.now;
8495
+ const start = await dash("POST", "/dashboard/cli/link", { slug });
8496
+ if (start.status !== 201 && start.status !== 200) {
8497
+ throw new Error(`could not start the link (${errText(start)})`);
8498
+ }
8499
+ const d = data(start);
8500
+ if (!d.device_code || !d.user_code) throw new Error("link start returned no device_code");
8501
+ io.log("");
8502
+ io.log(`To link '${slug}', approve this request in your browser:`);
8503
+ io.log("");
8504
+ io.log(` ${d.verification_uri_complete ?? d.verification_uri ?? "(open the dashboard \u2192 CLI link)"}`);
8505
+ io.log(` code: ${d.user_code}`);
8506
+ io.log("");
8507
+ io.log("Waiting for approval (Ctrl-C to cancel)\u2026");
8508
+ const intervalMs = Math.max(1, d.interval ?? 5) * 1e3;
8509
+ const deadline = now() + Math.max(1, d.expires_in ?? 900) * 1e3;
8510
+ while (now() < deadline) {
8511
+ await io.sleep(intervalMs);
8512
+ const poll = await dash("POST", "/dashboard/cli/link/token", { device_code: d.device_code });
8513
+ if (poll.status === 428) continue;
8514
+ if (poll.status === 200) {
8515
+ const p = data(poll);
8516
+ if (!p.api_key || !p.tenant_id) throw new Error("approval returned no api_key");
8517
+ return { slug: p.slug || slug, tenant_id: p.tenant_id, api_key: p.api_key };
8518
+ }
8519
+ throw new Error(`link failed (${errText(poll)})`);
8520
+ }
8521
+ throw new Error("link timed out waiting for browser approval \u2014 run `vxil link <slug>` again");
8522
+ }
8523
+ async function linkViaStoredKey(api, slug, storedKey) {
8524
+ let res;
8525
+ try {
8526
+ res = await api("GET", "/v1/features");
8527
+ } catch (e2) {
8528
+ return { invalid: `could not reach the edge (${e2.message})` };
8529
+ }
8530
+ if (res.status === 200) {
8531
+ const tenantId = res.body.meta?.tenant_id;
8532
+ return { result: { slug, tenant_id: tenantId && tenantId.length ? tenantId : "(unknown)", api_key: storedKey } };
8533
+ }
8534
+ const e = res.body.error;
8535
+ return { invalid: `${e?.code ?? res.status}${e?.message ? ` \u2014 ${e.message}` : ""}` };
8536
+ }
8537
+
8538
+ // src/members.ts
8539
+ var MEMBER_ROLES = ["viewer", "developer", "admin", "owner"];
8540
+ function parseMemberRole(raw) {
8541
+ if (raw === void 0 || !raw.trim()) return { error: `--role is required \u2014 one of ${MEMBER_ROLES.join("|")}` };
8542
+ const r = raw.trim();
8543
+ if (!MEMBER_ROLES.includes(r)) {
8544
+ return { error: `--role must be one of ${MEMBER_ROLES.join("|")} (got '${r}')` };
8545
+ }
8546
+ return { role: r };
8547
+ }
8548
+ function parseMemberEmail(raw) {
8549
+ const e = (raw ?? "").trim();
8550
+ if (!e || !e.includes("@")) return { error: "an e-mail address is required, e.g. vxil members invite ada@example.com --role developer" };
8551
+ return { email: e };
8552
+ }
8553
+ function parseInviteToken(raw) {
8554
+ const s = (raw ?? "").trim();
8555
+ if (!s) return { error: "usage: vxil invites accept <token | invite-url> [--json] \u2014 the token from the invitation e-mail" };
8556
+ if (/^https?:\/\//i.test(s)) {
8557
+ try {
8558
+ const t = new URL(s).searchParams.get("token");
8559
+ if (t && t.trim()) return { token: t.trim() };
8560
+ } catch {
8561
+ }
8562
+ return { error: "that URL carries no ?token= \u2014 paste the invitation link from the e-mail, or the token itself" };
8563
+ }
8564
+ return { token: s };
8565
+ }
8566
+ async function listMembersViaSession(dash, cookie, tenantId) {
8567
+ const m = await dash("GET", `/dashboard/tenants/${tenantId}/members`, void 0, cookie);
8568
+ if (m.status === 401) return { unauthorized: true };
8569
+ if (m.status !== 200) throw new Error(`could not list the members (${errText(m)})`);
8570
+ const p = await dash("GET", `/dashboard/tenants/${tenantId}/members/pending`, void 0, cookie);
8571
+ if (p.status === 401) return { unauthorized: true };
8572
+ const invites = p.status === 200 ? data(p).invites ?? [] : [];
8573
+ return { members: data(m).members ?? [], invites };
8574
+ }
8575
+ function inviteExpired(row2, now = Date.now()) {
8576
+ const t = Date.parse(row2.expires_at);
8577
+ return Number.isFinite(t) && t <= now;
8578
+ }
8579
+ function renderMembers(members, invites, now = Date.now()) {
8580
+ const lines = [];
8581
+ if (!members.length) lines.push("(no members)");
8582
+ for (const m of members) {
8583
+ lines.push(`${m.role.padEnd(9)} ${m.email}${m.display_name ? ` (${m.display_name})` : ""} ${m.user_id}`);
8584
+ }
8585
+ if (invites.length) {
8586
+ lines.push("");
8587
+ lines.push(`pending invitations (${invites.length}):`);
8588
+ for (const i of invites) {
8589
+ const state = inviteExpired(i, now) ? "EXPIRED" : `expires ${i.expires_at}`;
8590
+ lines.push(` ${i.role.padEnd(9)} ${i.email} ${i.id} ${state}`);
8591
+ }
8592
+ }
8593
+ return lines;
8594
+ }
8595
+ async function inviteMemberViaSession(dash, cookie, tenantId, req) {
8596
+ const r = await dash("POST", `/dashboard/tenants/${tenantId}/members/invite`, { email: req.email, role: req.role }, cookie);
8597
+ if (r.status === 401) return { unauthorized: true };
8598
+ if (r.status !== 202 && r.status !== 200 && r.status !== 201) {
8599
+ throw new Error(`could not invite ${req.email} (${errText(r)})`);
8600
+ }
8601
+ const d = data(r);
8602
+ return { email: d.email ?? req.email, role: d.role ?? req.role };
8603
+ }
8604
+ async function addMemberViaSession(dash, cookie, tenantId, req) {
8605
+ const r = await dash("POST", `/dashboard/tenants/${tenantId}/members`, { email: req.email, role: req.role }, cookie);
8606
+ if (r.status === 401) return { unauthorized: true };
8607
+ if (r.status !== 201 && r.status !== 200) throw new Error(`could not add ${req.email} (${errText(r)})`);
8608
+ const d = data(r);
8609
+ if (!d.user_id) throw new Error("the add returned no user_id");
8610
+ return { user_id: d.user_id, role: d.role ?? req.role };
8611
+ }
8612
+ function findMember(members, ref) {
8613
+ const r = ref.trim();
8614
+ const byId = members.find((m) => m.user_id === r);
8615
+ if (byId) return byId;
8616
+ const lower = r.toLowerCase();
8617
+ return members.find((m) => m.email.toLowerCase() === lower) ?? null;
8618
+ }
8619
+ async function removeMemberViaSession(dash, cookie, tenantId, userId) {
8620
+ const r = await dash("DELETE", `/dashboard/tenants/${tenantId}/members/${encodeURIComponent(userId)}`, void 0, cookie);
8621
+ if (r.status === 401) return { unauthorized: true };
8622
+ if (r.status !== 200 && r.status !== 204) throw new Error(`could not remove the member (${errText(r)})`);
8623
+ return { removed: userId };
8624
+ }
8625
+ function findPendingInvite(invites, ref) {
8626
+ const r = ref.trim();
8627
+ const byId = invites.find((i) => i.id === r);
8628
+ if (byId) return byId;
8629
+ const lower = r.toLowerCase();
8630
+ return invites.find((i) => i.email.toLowerCase() === lower) ?? null;
8631
+ }
8632
+ async function revokeInviteViaSession(dash, cookie, tenantId, inviteId) {
8633
+ const r = await dash("DELETE", `/dashboard/tenants/${tenantId}/members/pending/${encodeURIComponent(inviteId)}`, void 0, cookie);
8634
+ if (r.status === 401) return { unauthorized: true };
8635
+ if (r.status !== 200) throw new Error(`could not revoke the invitation (${errText(r)})`);
8636
+ const d = data(r);
8637
+ return { revoked: d.revoked ?? inviteId };
8638
+ }
8639
+ async function lookupInvite(dash, token) {
8640
+ const r = await dash("GET", `/dashboard/invites/pending?token=${encodeURIComponent(token)}`);
8641
+ if (r.status !== 200) return { refused: errText(r) };
8642
+ const d = data(r);
8643
+ return {
8644
+ preview: {
8645
+ tenant_display_name: String(d.tenant_display_name ?? "a project"),
8646
+ role: String(d.role ?? ""),
8647
+ expires_at: String(d.expires_at ?? "")
8648
+ }
8649
+ };
8650
+ }
8651
+ async function acceptInviteViaSession(dash, cookie, token) {
8652
+ const r = await dash("POST", "/dashboard/invites/accept", { token }, cookie);
8653
+ if (r.status === 401) return { unauthorized: true };
8654
+ if (r.status !== 200) throw new Error(`could not accept the invitation (${errText(r)})`);
8655
+ const d = data(r);
8656
+ if (!d.tenant_id) throw new Error("the accept returned no tenant_id");
8657
+ return { tenant_id: d.tenant_id, role: d.role ?? "" };
8658
+ }
8659
+
8660
+ // src/projects.ts
8661
+ async function listProjectsViaSession(dash, cookie) {
8662
+ const r = await dash("GET", "/dashboard/tenants", void 0, cookie);
8663
+ if (r.status === 401) return { unauthorized: true };
8664
+ if (r.status !== 200) throw new Error(`could not list projects (${errText(r)})`);
8665
+ return { tenants: data(r).tenants ?? [] };
8666
+ }
8667
+ function renderProjects(tenants, bound) {
8668
+ if (!tenants.length) return ["(no projects on this account \u2014 `vxil projects create <slug> --name <display>`)"];
8669
+ return tenants.map((t) => {
8670
+ const slot = bound.get(t.slug);
8671
+ const kind = t.kind === "dev" ? ` \xB7 dev${t.expires_at ? `, expires ${t.expires_at}` : ""}` : "";
8672
+ return `${slot ? `[${slot}]`.padEnd(14) : "".padEnd(14)}${t.slug} ${t.display_name} ${t.tier}${t.role ? ` \xB7 ${t.role}` : ""}${kind}`;
8673
+ });
8674
+ }
8675
+ function parseProjectCreate(slug, name) {
8676
+ const s = (slug ?? "").trim();
8677
+ if (!s) return { error: "usage: vxil projects create <slug> [--name <display name>] [--json]" };
8678
+ if (!TENANT_SLUG_RE.test(s)) return { error: `'${s}' is not a valid slug \u2014 lowercase letters, digits and hyphens (${TENANT_SLUG_RE.source})` };
8679
+ const display = (name ?? "").trim() || s;
8680
+ return { body: { slug: s, display_name: display } };
8681
+ }
8682
+ async function createProjectViaSession(dash, cookie, body) {
8683
+ const r = await dash("POST", "/dashboard/tenants", { slug: body.slug, display_name: body.display_name }, cookie);
8684
+ if (r.status === 401) return { unauthorized: true };
8685
+ if (r.status !== 201 && r.status !== 200) throw new Error(`could not create '${body.slug}' (${errText(r)})`);
8686
+ const d = data(r);
8687
+ if (!d.id) throw new Error("the create returned no project id");
8688
+ return {
8689
+ project: {
8690
+ id: d.id,
8691
+ slug: d.slug ?? body.slug,
8692
+ display_name: d.display_name ?? body.display_name,
8693
+ tier: d.tier ?? "free",
8694
+ kind: d.kind ?? null,
8695
+ expires_at: d.expires_at ?? null
8696
+ }
8697
+ };
8698
+ }
8699
+ async function deleteProjectViaSession(dash, cookie, tenantId, slug) {
8700
+ const r = await dash("DELETE", `/dashboard/tenants/${tenantId}`, { confirm: slug }, cookie);
8701
+ if (r.status === 401) return { unauthorized: true };
8702
+ if (r.status !== 200) throw new Error(`could not delete '${slug}' (${errText(r)})`);
8703
+ const { deleted: _d, ...report } = data(r);
8704
+ void _d;
8705
+ return { report };
8706
+ }
8707
+ function cleanupAfterDelete(creds, proj, gone) {
8708
+ const cleared = [];
8709
+ let credsChanged = false;
8710
+ if (creds.keys?.[gone.slug] !== void 0) {
8711
+ const keys = { ...creds.keys };
8712
+ delete keys[gone.slug];
8713
+ creds = { ...creds, keys };
8714
+ credsChanged = true;
8715
+ cleared.push("key");
8716
+ }
8717
+ let projChanged = false;
8718
+ let primaryWasIt = false;
8719
+ if (proj) {
8720
+ const next = { ...proj };
8721
+ if (next.dev && next.dev.tenant_id === gone.tenant_id) {
8722
+ delete next.dev;
8723
+ projChanged = true;
8724
+ cleared.push("dev slot");
8725
+ }
8726
+ for (const kind of ["targets", "branches"]) {
8727
+ const map2 = next[kind];
8728
+ if (!map2) continue;
8729
+ const kept = {};
8730
+ for (const [n, s] of Object.entries(map2)) {
8731
+ if (s.tenant_id === gone.tenant_id) {
8732
+ projChanged = true;
8733
+ cleared.push(`${kind === "targets" ? "target" : "branch"} '${n}'`);
8734
+ } else kept[n] = s;
8735
+ }
8736
+ if (Object.keys(kept).length) next[kind] = kept;
8737
+ else delete next[kind];
8738
+ }
8739
+ if (proj.tenant_id === gone.tenant_id) primaryWasIt = true;
8740
+ proj = projChanged ? next : proj;
8741
+ }
8742
+ return { creds, credsChanged, proj, projChanged, primaryWasIt, cleared };
8743
+ }
8744
+ async function getBillingViaSession(dash, cookie, tenantId) {
8745
+ const r = await dash("GET", `/dashboard/tenants/${tenantId}/billing`, void 0, cookie);
8746
+ if (r.status === 401) return { unauthorized: true };
8747
+ if (r.status !== 200) throw new Error(`could not read the billing status (${errText(r)})`);
8748
+ const d = data(r);
8749
+ return {
8750
+ billing: {
8751
+ tier: d.tier ?? "free",
8752
+ provider: d.provider ?? "(unknown)",
8753
+ plans: d.plans ?? [],
8754
+ subscription: d.subscription ?? null,
8755
+ scheduled_downgrade: d.scheduled_downgrade ?? null,
8756
+ checkouts: d.checkouts ?? []
8757
+ }
8758
+ };
8759
+ }
8760
+ function renderBilling(b2) {
8761
+ const lines = [`tier: ${b2.tier} \xB7 provider: ${b2.provider}`];
8762
+ const s = b2.subscription;
8763
+ if (s) {
8764
+ lines.push(`subscription: ${s.status ?? "\u2014"}${s.tier ? ` (${s.tier})` : ""}${s.current_period_end ? ` \xB7 current period ends ${s.current_period_end}` : ""}`);
8765
+ } else lines.push("subscription: none");
8766
+ if (b2.scheduled_downgrade) lines.push(`scheduled: moves to ${b2.scheduled_downgrade.tier} at ${b2.scheduled_downgrade.effective_at ?? "(period end)"}`);
8767
+ if (b2.plans.length) lines.push(`plans: ${b2.plans.map((p) => `${p.tier}${p.self_serve === false ? " (contract)" : ""}`).join(" \xB7 ")}`);
8768
+ if (b2.checkouts.length) {
8769
+ lines.push(`recent checkouts (${b2.checkouts.length}):`);
8770
+ for (const c of b2.checkouts.slice(0, 5)) lines.push(` ${c.created_at} ${c.tier.padEnd(10)} ${c.status} ${c.id}`);
8771
+ }
8772
+ return lines;
8773
+ }
8774
+ async function checkoutViaSession(dash, cookie, tenantId, tier) {
8775
+ const r = await dash("POST", `/dashboard/tenants/${tenantId}/billing/checkout`, { tier }, cookie);
8776
+ if (r.status === 401) return { unauthorized: true };
8777
+ if (r.status !== 200 && r.status !== 201 && r.status !== 202) throw new Error(`could not change the plan (${errText(r)})`);
8778
+ const d = data(r);
8779
+ const checkout_id = d.checkout_id ?? "";
8780
+ const t = d.tier ?? tier;
8781
+ let outcome;
8782
+ if (d.checkout_url) outcome = { kind: "checkout_url", checkout_id, tier: t, checkout_url: d.checkout_url };
8783
+ else if (d.resumed) outcome = { kind: "resumed", checkout_id, tier: t };
8784
+ else if (d.scheduled) outcome = { kind: "scheduled", checkout_id, tier: t, effective_at: d.effective_at ?? null };
8785
+ else if (d.completed) outcome = { kind: "completed", checkout_id, tier: t };
8786
+ else throw new Error(`the checkout answered with an unexpected shape (${JSON.stringify(d).slice(0, 160)})`);
8787
+ return { outcome, raw: d };
8788
+ }
8789
+ function renderCheckout(o) {
8790
+ switch (o.kind) {
8791
+ case "checkout_url":
8792
+ return { stdout: o.checkout_url, stderr: [`open it in a browser to complete the ${o.tier} checkout; the plan changes when the provider confirms (\`vxil billing status\` shows it)`] };
8793
+ case "completed":
8794
+ return { stdout: `\u2713 now on ${o.tier}`, stderr: [] };
8795
+ case "scheduled":
8796
+ return { stdout: `\u2713 moves to ${o.tier} at ${o.effective_at ?? "the end of the current period"}`, stderr: ["the current plan stays until then; picking it again before that cancels the move"] };
8797
+ case "resumed":
8798
+ return { stdout: `\u2713 staying on ${o.tier} \u2014 the scheduled downgrade is cancelled`, stderr: [] };
8799
+ }
8800
+ }
8801
+
8802
+ // src/operatorVerbs.ts
8803
+ function ok2xx(r) {
8804
+ return r.status >= 200 && r.status < 300;
8805
+ }
8806
+ function dataOf(r) {
8807
+ return r.body.data ?? {};
8808
+ }
8809
+ var CONTENT_TYPES = {
8810
+ txt: "text/plain",
8811
+ md: "text/markdown",
8812
+ html: "text/html",
8813
+ css: "text/css",
8814
+ csv: "text/csv",
8815
+ json: "application/json",
8816
+ js: "text/javascript",
8817
+ mjs: "text/javascript",
8818
+ ts: "text/plain",
8819
+ xml: "application/xml",
8820
+ pdf: "application/pdf",
8821
+ zip: "application/zip",
8822
+ gz: "application/gzip",
8823
+ png: "image/png",
8824
+ jpg: "image/jpeg",
8825
+ jpeg: "image/jpeg",
8826
+ gif: "image/gif",
8827
+ webp: "image/webp",
8828
+ svg: "image/svg+xml",
8829
+ mp3: "audio/mpeg",
8830
+ mp4: "video/mp4",
8831
+ webm: "video/webm",
8832
+ wav: "audio/wav"
8833
+ };
8834
+ function contentTypeFor(filename, override) {
8835
+ if (override && override.trim()) return override.trim();
8836
+ const ext = filename.toLowerCase().split(".").pop() ?? "";
8837
+ return CONTENT_TYPES[ext] ?? "application/octet-stream";
8838
+ }
8839
+ async function filesPut(api, put, req) {
8840
+ const mint = await api("POST", "/v1/files/upload-url", {
8841
+ user_id: req.user_id,
8842
+ filename: req.filename,
8843
+ content_type: req.content_type,
8844
+ size_bytes: req.bytes.byteLength
8845
+ });
8846
+ if (!ok2xx(mint)) throw new Error(`could not mint an upload URL (${apiErrText(mint)})`);
8847
+ const m = dataOf(mint);
8848
+ if (!m.object_id || !m.upload_url) throw new Error("the upload-url route returned no object_id/upload_url");
8849
+ const r = await put(m.upload_url, (m.upload_method ?? "PUT").toUpperCase(), req.bytes, req.content_type);
8850
+ if (r.status < 200 || r.status >= 300) {
8851
+ throw new Error(`the upload PUT failed (${r.status}${r.text ? ` ${r.text.slice(0, 120)}` : ""}) \u2014 object ${m.object_id} was NOT completed`);
8852
+ }
8853
+ const done = await api("POST", `/v1/files/${encodeURIComponent(m.object_id)}/complete`);
8854
+ if (!ok2xx(done)) throw new Error(`the bytes are uploaded but complete failed (${apiErrText(done)}) \u2014 retry: vxil api POST /v1/files/${m.object_id}/complete`);
8855
+ const d = dataOf(done);
8856
+ return { object_id: d.object_id ?? m.object_id, status: d.status ?? "ready", size_bytes: req.bytes.byteLength, content_type: req.content_type };
8857
+ }
8858
+ async function filesRm(api, objectId) {
8859
+ const r = await api("DELETE", `/v1/files/${encodeURIComponent(objectId)}`);
8860
+ if (!ok2xx(r)) throw new Error(`could not delete '${objectId}' (${apiErrText(r)})`);
8861
+ return { object_id: objectId, deleted: true };
8862
+ }
8863
+ var WEBHOOK_PROVIDERS = ["stripe", "paddle", "github", "slack", "revenuecat", "generic"];
8864
+ function parseSourceAdd(f) {
8865
+ const provider = (f.provider ?? "").trim();
8866
+ if (!WEBHOOK_PROVIDERS.includes(provider)) {
8867
+ return { error: `--provider must be one of ${WEBHOOK_PROVIDERS.join("|")}${provider ? ` (got '${provider}')` : ""}` };
8868
+ }
8869
+ const name = (f.name ?? "").trim();
8870
+ if (!name) return { error: "--name is required \u2014 a label for this source, e.g. --name stripe-prod" };
8871
+ if (name.length > 200) return { error: `--name is at most 200 characters (got ${name.length})` };
8872
+ const fwd = f.forwardUrl?.trim();
8873
+ if (fwd !== void 0 && fwd !== "" && !/^https:\/\//i.test(fwd)) return { error: "--forward-url must be an https:// URL" };
8874
+ return { body: { provider, name, ...fwd ? { forward_url: fwd } : {} } };
8875
+ }
8876
+ async function sourcesAdd(api, body) {
8877
+ const r = await api("POST", "/v1/webhooks/sources", body);
8878
+ if (!ok2xx(r)) throw new Error(`could not register the source (${apiErrText(r)})`);
8879
+ const d = dataOf(r);
8880
+ if (!d.source_id || !d.receiver_url_path) throw new Error("the source route returned no source_id/receiver_url_path");
8881
+ return { source_id: d.source_id, receiver_url_path: d.receiver_url_path, provider: d.provider ?? body.provider };
8882
+ }
8883
+ function sourceAddStdout(s, edgeBase, json2) {
8884
+ if (json2) return JSON.stringify({ source_id: s.source_id, provider: s.provider, receiver_url_path: s.receiver_url_path, receiver_url: `${edgeBase.replace(/\/$/, "")}${s.receiver_url_path}` });
8885
+ return `${edgeBase.replace(/\/$/, "")}${s.receiver_url_path}`;
8886
+ }
8887
+ function sourceAddNotices(s) {
8888
+ return [
8889
+ `\u2713 registered ${s.provider} source ${s.source_id} \u2014 the receiver URL above is shown ONCE; paste it into the provider's webhook settings`,
8890
+ `a signing secret goes in \`vxil secrets set webhooks/source_${s.source_id}\` (a 'generic' source needs none); \`vxil listen --source ${s.source_id} --forward-to <url>\` forwards its events locally`
8891
+ ];
8892
+ }
8893
+ async function sourcesList(api) {
8894
+ const r = await api("GET", "/v1/webhooks/sources");
8895
+ if (!ok2xx(r)) throw new Error(`could not list the sources (${apiErrText(r)})`);
8896
+ return dataOf(r).sources ?? [];
8897
+ }
8898
+ async function sourcesRm(api, sourceId) {
8899
+ const r = await api("DELETE", `/v1/webhooks/sources/${encodeURIComponent(sourceId)}`);
8900
+ if (!ok2xx(r)) throw new Error(`could not delete source '${sourceId}' (${apiErrText(r)})`);
8901
+ return { source_id: sourceId, deleted: true };
8902
+ }
8903
+ async function sourcesTest(api, sourceId) {
8904
+ const r = await api("POST", `/v1/webhooks/sources/${encodeURIComponent(sourceId)}/test`);
8905
+ if (!ok2xx(r)) throw new Error(`could not send a test event to source '${sourceId}' (${apiErrText(r)})`);
8906
+ return { ...dataOf(r), pending: r.status === 202 };
8907
+ }
8908
+ async function eventsReplay(api, eventId) {
8909
+ const r = await api("POST", `/v1/webhooks/events/${encodeURIComponent(eventId)}/replay`);
8910
+ if (!ok2xx(r)) throw new Error(`could not replay event '${eventId}' (${apiErrText(r)})`);
8911
+ return { ...dataOf(r), event_id: eventId, replayed: true };
8912
+ }
8913
+ async function eventsRuns(api, eventId) {
8914
+ const r = await api("GET", `/v1/webhooks/events/${encodeURIComponent(eventId)}/runs`);
8915
+ if (!ok2xx(r)) throw new Error(`could not read the runs of event '${eventId}' (${apiErrText(r)})`);
8916
+ return dataOf(r);
8917
+ }
8918
+ function parseBulkFilter(raw) {
8919
+ if (raw === void 0) return { error: "--filter '<json>' is required \u2014 the SAME filter grammar as a query; an unselected sweep is refused server-side, so '{}' is refused here too" };
8920
+ let v;
8921
+ try {
8922
+ v = JSON.parse(raw);
8923
+ } catch {
8924
+ return { error: "--filter must be valid JSON" };
8925
+ }
8926
+ if (!v || typeof v !== "object" || Array.isArray(v)) return { error: "--filter must be a JSON object" };
8927
+ if (!Object.keys(v).length) return { error: "an empty filter ('{}') would select every item \u2014 name at least one field (the server refuses an unselected sweep)" };
8928
+ return { filter: v };
8929
+ }
8930
+ function parseBulkLimit(raw) {
8931
+ if (raw === void 0) return { limit: void 0 };
8932
+ const n = Number(raw);
8933
+ if (!Number.isInteger(n) || n < 1 || n > 100) return { error: `--limit '${raw}': a whole number 1..100 (items per round trip; the server default is 25)` };
8934
+ return { limit: n };
8935
+ }
8936
+ var BULK_RM_MAX_PAGES = 1e3;
8937
+ var BULK_RM_DRY_RUN_PAGE = 100;
8938
+ async function bulkPage(api, collection, filter, limit, cursor, dryRun) {
8939
+ const r = await api("POST", `/v1/cms/items/${encodeURIComponent(collection)}/delete`, {
8940
+ filter,
8941
+ ...limit !== void 0 ? { limit } : {},
8942
+ ...cursor ? { cursor } : {},
8943
+ ...dryRun ? { dry_run: true } : {}
8944
+ });
8945
+ if (!ok2xx(r)) throw new Error(`bulk delete on '${collection}' ${dryRun ? "dry run " : ""}failed (${apiErrText(r)})`);
8946
+ const d = dataOf(r);
8947
+ return {
8948
+ matched: d.matched ?? 0,
8949
+ deleted: d.deleted ?? 0,
8950
+ cascaded: d.cascaded ?? 0,
8951
+ set_null: d.set_null ?? 0,
8952
+ dry_run: d.dry_run ?? dryRun,
8953
+ next_cursor: d.next_cursor ?? null
8954
+ };
8955
+ }
8956
+ async function bulkRmDryRun(api, collection, filter) {
8957
+ let matched = 0;
8958
+ let pages = 0;
8959
+ let cursor;
8960
+ for (; ; ) {
8961
+ const p = await bulkPage(api, collection, filter, BULK_RM_DRY_RUN_PAGE, cursor, true);
8962
+ matched += p.matched;
8963
+ pages++;
8964
+ if (!p.next_cursor) return { matched, pages, truncated: false };
8965
+ if (pages >= BULK_RM_MAX_PAGES) return { matched, pages, truncated: true };
8966
+ cursor = p.next_cursor;
8967
+ }
8968
+ }
8969
+ async function bulkRmAll(api, collection, filter, limit, onPage) {
8970
+ const totals = { deleted: 0, cascaded: 0, set_null: 0 };
8971
+ let pages = 0;
8972
+ for (; ; ) {
8973
+ let p;
8974
+ try {
8975
+ p = await bulkPage(api, collection, filter, limit, void 0, false);
8976
+ } catch (e) {
8977
+ throw new Error(`${e.message} \u2014 ${totals.deleted} item(s) were already deleted in the pages before it`);
8978
+ }
8979
+ pages++;
8980
+ totals.deleted += p.deleted;
8981
+ totals.cascaded += p.cascaded;
8982
+ totals.set_null += p.set_null;
8983
+ onPage?.(p, totals);
8984
+ if (p.deleted === 0) return { ...totals, pages, truncated: false };
8985
+ if (pages >= BULK_RM_MAX_PAGES) return { ...totals, pages, truncated: true };
8986
+ }
8987
+ }
8988
+ async function searchSyncRun(api, sourceKey, opts = {}) {
8989
+ const r = await api("POST", `/v1/search/sync/${encodeURIComponent(sourceKey)}/run`, opts.sweep ? { sweep: true } : {});
8990
+ if (!ok2xx(r)) throw new Error(`sync run for '${sourceKey}' failed (${apiErrText(r)})`);
8991
+ return dataOf(r);
8992
+ }
8993
+ async function searchSyncReset(api, sourceKey, opts = {}) {
8994
+ const r = await api("POST", `/v1/search/sync/${encodeURIComponent(sourceKey)}/reset`, opts.purge ? { purge: true } : {});
8995
+ if (!ok2xx(r)) throw new Error(`sync reset for '${sourceKey}' failed (${apiErrText(r)})`);
8996
+ return dataOf(r);
8997
+ }
8998
+ function splitCsvLine(line) {
8999
+ const cells = [];
9000
+ let cur = "";
9001
+ let i = 0;
9002
+ while (i <= line.length) {
9003
+ if (i === line.length) {
9004
+ cells.push(cur.trim());
9005
+ break;
9006
+ }
9007
+ const ch = line[i];
9008
+ if (ch === '"' && cur.trim() === "") {
9009
+ let j = i + 1;
9010
+ let val = "";
9011
+ for (; ; ) {
9012
+ if (j >= line.length) return { error: "an unterminated quoted field" };
9013
+ if (line[j] === '"') {
9014
+ if (line[j + 1] === '"') {
9015
+ val += '"';
9016
+ j += 2;
9017
+ continue;
9018
+ }
9019
+ j++;
9020
+ break;
9021
+ }
9022
+ val += line[j];
9023
+ j++;
9024
+ }
9025
+ const rest2 = line.slice(j);
9026
+ const m = /^\s*(,|$)/.exec(rest2);
9027
+ if (!m) return { error: `unexpected text after a closing quote ("${rest2.trim().slice(0, 12)}\u2026")` };
9028
+ cells.push(val);
9029
+ cur = "";
9030
+ i = j + m[0].length;
9031
+ if (m[1] === "") break;
9032
+ if (i === line.length) {
9033
+ cells.push("");
9034
+ break;
9035
+ }
9036
+ continue;
9037
+ }
9038
+ if (ch === ",") {
9039
+ cells.push(cur.trim());
9040
+ cur = "";
9041
+ i++;
9042
+ continue;
9043
+ }
9044
+ cur += ch;
9045
+ i++;
9046
+ }
9047
+ return { cells };
9048
+ }
9049
+ function parseUsersFile(text, filename) {
9050
+ const lower = filename.toLowerCase();
9051
+ if (lower.endsWith(".json")) {
9052
+ let v;
9053
+ try {
9054
+ v = JSON.parse(text);
9055
+ } catch {
9056
+ return { error: `${filename} is not valid JSON` };
9057
+ }
9058
+ const arr = Array.isArray(v) ? v : v && typeof v === "object" && Array.isArray(v.users) ? v.users : null;
9059
+ if (!arr) return { error: `${filename} must be a JSON array of users or { "users": [...] }` };
9060
+ const users = [];
9061
+ for (const [i, u] of arr.entries()) {
9062
+ const o = u;
9063
+ if (!o || typeof o !== "object" || typeof o.id !== "string" || !o.id.trim()) return { error: `${filename}: users[${i}] has no string id` };
9064
+ users.push({
9065
+ id: o.id.trim(),
9066
+ ...typeof o.email === "string" && o.email ? { email: o.email } : {},
9067
+ ...typeof o.display_name === "string" && o.display_name ? { display_name: o.display_name } : {},
9068
+ ...o.attributes && typeof o.attributes === "object" ? { attributes: o.attributes } : {}
9069
+ });
9070
+ }
9071
+ return users.length ? { users } : { error: `${filename} holds no users` };
9072
+ }
9073
+ if (lower.endsWith(".csv")) {
9074
+ const lines = text.split(/\r?\n/).filter((l) => l.trim());
9075
+ if (lines.length < 2) return { error: `${filename} needs a header row (id[,email[,display_name]]) and at least one user` };
9076
+ const head = splitCsvLine(lines[0]);
9077
+ if ("error" in head) return { error: `${filename}: the header row is not valid CSV (${head.error})` };
9078
+ const header = head.cells.map((h) => h.trim().toLowerCase());
9079
+ const idCol = header.indexOf("id");
9080
+ if (idCol < 0) return { error: `${filename}: the header row has no 'id' column (got: ${header.join(", ")})` };
9081
+ const emailCol = header.indexOf("email");
9082
+ const nameCol = header.indexOf("display_name");
9083
+ const users = [];
9084
+ for (const [i, line] of lines.slice(1).entries()) {
9085
+ const split = splitCsvLine(line);
9086
+ if ("error" in split) return { error: `${filename}: row ${i + 2} is not valid CSV (${split.error}) \u2014 quote a field that holds a comma as "Doe, Jane", or use the .json form` };
9087
+ const { cells } = split;
9088
+ if (cells.length !== header.length) {
9089
+ return { error: `${filename}: row ${i + 2} has ${cells.length} cell(s) but the header has ${header.length} \u2014 a comma inside a value must be quoted ("Doe, Jane"), or use the .json form; nothing was imported` };
9090
+ }
9091
+ const id = cells[idCol] ?? "";
9092
+ if (!id) return { error: `${filename}: row ${i + 2} has an empty id` };
9093
+ const email = emailCol >= 0 ? cells[emailCol] : void 0;
9094
+ const name = nameCol >= 0 ? cells[nameCol] : void 0;
9095
+ users.push({ id, ...email ? { email } : {}, ...name ? { display_name: name } : {} });
9096
+ }
9097
+ return { users };
9098
+ }
9099
+ return { error: `${filename}: expected a .json or .csv file` };
9100
+ }
9101
+ var USERS_IMPORT_CHUNK = 100;
9102
+ async function importUsers(api, users, opts = {}) {
9103
+ const size2 = Math.max(1, Math.min(USERS_IMPORT_CHUNK, opts.chunk ?? USERS_IMPORT_CHUNK));
9104
+ let upserted = 0;
9105
+ let chunks = 0;
9106
+ for (let i = 0; i < users.length; i += size2) {
9107
+ const slice = users.slice(i, i + size2);
9108
+ const r = await api("POST", "/v1/users/bulk", { users: slice });
9109
+ if (!ok2xx(r)) throw new Error(`users import failed at user ${i + 1} of ${users.length} (${apiErrText(r)}) \u2014 ${upserted} already upserted; the call is an upsert, so re-running is safe`);
9110
+ upserted += dataOf(r).upserted ?? slice.length;
9111
+ chunks++;
9112
+ opts.onProgress?.(Math.min(i + size2, users.length), users.length);
9113
+ }
9114
+ return { upserted, chunks };
9115
+ }
8282
9116
 
8283
9117
  // src/fnDev.ts
8284
9118
  var DEV_VXIL_BASE = "https://api.vxil.org";
@@ -8554,11 +9388,20 @@ ${relLines.join("\n")}
8554
9388
  lines.push(` };`);
8555
9389
  return lines.join("\n");
8556
9390
  }
9391
+ function compareCodePoints(a, b2) {
9392
+ return a < b2 ? -1 : a > b2 ? 1 : 0;
9393
+ }
9394
+ function sortKeysDeep(v) {
9395
+ if (Array.isArray(v)) return v.map(sortKeysDeep);
9396
+ if (v === null || typeof v !== "object") return v;
9397
+ const o = v;
9398
+ return Object.fromEntries(Object.keys(o).sort(compareCodePoints).map((k) => [k, sortKeysDeep(o[k])]));
9399
+ }
8557
9400
  function lowerSig(sig) {
8558
9401
  if (sig == null) return "unknown";
8559
9402
  if (typeof sig === "string") return sig.trim() || "unknown";
8560
9403
  if (typeof sig === "object" && !Array.isArray(sig)) {
8561
- const fields = Object.entries(sig).map(([k, v]) => `${quoteKey(k)}: ${typeof v === "string" && v.trim() ? v.trim() : "unknown"}`);
9404
+ const fields = Object.entries(sig).sort(([a], [b2]) => compareCodePoints(a, b2)).map(([k, v]) => `${quoteKey(k)}: ${typeof v === "string" && v.trim() ? v.trim() : "unknown"}`);
8562
9405
  return fields.length ? `{ ${fields.join("; ")} }` : "Record<string, never>";
8563
9406
  }
8564
9407
  return "unknown";
@@ -8569,12 +9412,70 @@ function functionType(f) {
8569
9412
  function apiVersionEntries(input) {
8570
9413
  const map2 = input.apiVersions;
8571
9414
  if (!map2) return [];
8572
- return Object.entries(map2).sort(([a], [b2]) => a.localeCompare(b2));
9415
+ return Object.entries(map2).sort(([a], [b2]) => compareCodePoints(a, b2));
9416
+ }
9417
+ var API_VERSIONS_BLOCK_HEAD = "\n\n// The API majors this client was generated against (feature-versioning.md \xA7D10).\n// A new major on the backend surfaces here as `vxil gen --check` drift.\nexport const API_VERSIONS = {\n";
9418
+ var API_VERSIONS_BLOCK_TAIL = "\n} as const;";
9419
+ function hasApiVersionsBlock(src) {
9420
+ return src.includes(API_VERSIONS_BLOCK_HEAD);
9421
+ }
9422
+ function stripApiVersionsBlock(src) {
9423
+ const i = src.indexOf(API_VERSIONS_BLOCK_HEAD);
9424
+ if (i < 0) return src;
9425
+ const j = src.indexOf(API_VERSIONS_BLOCK_TAIL, i + API_VERSIONS_BLOCK_HEAD.length);
9426
+ if (j < 0) return src;
9427
+ return src.slice(0, i) + src.slice(j + API_VERSIONS_BLOCK_TAIL.length);
9428
+ }
9429
+ function canonicalCollections(collections) {
9430
+ return [...collections].sort((a, b2) => compareCodePoints(a.collection, b2.collection)).map((c) => ({ ...c, fields: [...c.fields].sort((a, b2) => compareCodePoints(a.field, b2.field)) }));
9431
+ }
9432
+ function genInputFromConfig(cfg) {
9433
+ return {
9434
+ ...cfg.env ? { env: cfg.env } : {},
9435
+ features: Object.keys(cfg.features),
9436
+ collections: Object.entries(cfg.cms?.collections ?? {}).map(([collection, def]) => ({
9437
+ collection,
9438
+ ...def.singular ? { singular: def.singular } : {},
9439
+ fields: Object.entries(def.fields).map(([field, f]) => ({
9440
+ field,
9441
+ type: f.type,
9442
+ required: !!f.required,
9443
+ index_slot: f.indexSlot ?? null,
9444
+ ...f.relationTo ? { relation_to: f.relationTo } : {},
9445
+ ...f.computed ? { computed: f.computed } : {},
9446
+ // `validation.enum` becomes a string-literal union in the generated
9447
+ // types, so the OFFLINE collector must carry it too (the online path
9448
+ // gets it from GET /v1/cms/collections, which returns the whole row) —
9449
+ // else `vxil gen --offline` and `vxil gen` would disagree.
9450
+ ...f.validation ? { validation: f.validation } : {}
9451
+ }))
9452
+ })),
9453
+ functions: Object.entries(cfg.functions ?? {}).map(([name, def]) => ({
9454
+ name,
9455
+ ...def.signature ? { signature: def.signature } : {}
9456
+ }))
9457
+ };
9458
+ }
9459
+ function typesCheckBody(src) {
9460
+ const i = src.indexOf("export interface VxilSchema");
9461
+ return i >= 0 ? src.slice(i) : src;
9462
+ }
9463
+ function checkGeneratedTypes(existing, generated, opts) {
9464
+ let have = typesCheckBody(existing);
9465
+ let want = typesCheckBody(generated);
9466
+ let unpinnedNotice = false;
9467
+ if (opts.offline) {
9468
+ have = stripApiVersionsBlock(have);
9469
+ } else if (existing && hasApiVersionsBlock(generated) && !hasApiVersionsBlock(existing)) {
9470
+ unpinnedNotice = true;
9471
+ want = stripApiVersionsBlock(want);
9472
+ }
9473
+ return { upToDate: existing !== "" && have === want, unpinnedNotice };
8573
9474
  }
8574
9475
  function generateTypes(input) {
8575
- const feats = [...input.features].sort();
8576
- const colls = [...input.collections].sort((a, b2) => a.collection.localeCompare(b2.collection));
8577
- const fns = [...input.functions].sort((a, b2) => a.name.localeCompare(b2.name));
9476
+ const feats = [...input.features].sort(compareCodePoints);
9477
+ const colls = canonicalCollections(input.collections);
9478
+ const fns = [...input.functions].sort((a, b2) => compareCodePoints(a.name, b2.name));
8578
9479
  const apiVers = apiVersionEntries(input);
8579
9480
  const header = [
8580
9481
  `// vxil.types.ts \u2014 GENERATED by \`vxil gen\`${input.generatedAt ? ` at ${input.generatedAt}` : ""}`,
@@ -8585,13 +9486,7 @@ function generateTypes(input) {
8585
9486
  "// DO NOT EDIT \u2014 re-run `vxil gen`.",
8586
9487
  "/* eslint-disable */"
8587
9488
  ].join("\n");
8588
- const apiVersionsBlock = apiVers.length ? `
8589
-
8590
- // The API majors this client was generated against (feature-versioning.md \xA7D10).
8591
- // A new major on the backend surfaces here as \`vxil gen --check\` drift.
8592
- export const API_VERSIONS = {
8593
- ` + apiVers.map(([f, v]) => ` ${quoteKey(f)}: [${v.map((x) => JSON.stringify(x)).join(", ")}],`).join("\n") + `
8594
- } as const;` : "";
9489
+ const apiVersionsBlock = apiVers.length ? API_VERSIONS_BLOCK_HEAD + apiVers.map(([f, v]) => ` ${quoteKey(f)}: [${v.map((x) => JSON.stringify(x)).join(", ")}],`).join("\n") + API_VERSIONS_BLOCK_TAIL : "";
8595
9490
  const cmsBody = colls.length ? colls.map(collectionType).join("\n") : " // (no collections \u2014 `vx.from(...)` is empty until you declare one)";
8596
9491
  const enumAliases = colls.some(hasEnumFilter) ? `
8597
9492
  export type VxilFilterEnum<U extends string | number | boolean> = U | { $eq?: U; $in?: U[]; $contains?: string };
@@ -8657,6 +9552,20 @@ var TOOLS = [
8657
9552
  method: "POST",
8658
9553
  path: "/v1/users"
8659
9554
  },
9555
+ {
9556
+ name: "users_merge",
9557
+ description: "Merge a REGISTRY-ONLY end-user (a row created with users_upsert that never signed in) INTO another user of the same tenant: attributes fill the survivor's gaps (the survivor wins on conflicts), the merged id is soft-deleted, its owner-scoped cms/files/payments rows move to the survivor, and auth.user.merged { from, into, method: 'registry_merge' } is audited. Refused 409 has_auth_identity when the id already has a sign-in identity (merge that through the auth flows). Server-only. Needs users:write.",
9558
+ inputSchema: {
9559
+ type: "object",
9560
+ properties: {
9561
+ id: { type: "string", description: "The registry-only user id to fold away (\u2264256 chars)." },
9562
+ into: { type: "string", description: "The surviving user id (same tenant)." }
9563
+ },
9564
+ required: ["id", "into"]
9565
+ },
9566
+ method: "POST",
9567
+ path: (a) => `/v1/users/${encodeURIComponent(String(a.id))}/merge`
9568
+ },
8660
9569
  {
8661
9570
  name: "notifications_send",
8662
9571
  feature: "notifications",
@@ -9700,6 +10609,21 @@ var TOOLS = [
9700
10609
  method: "GET",
9701
10610
  path: "/v1/webhooks/subscriptions"
9702
10611
  },
10612
+ {
10613
+ name: "webhooks_update_subscription",
10614
+ feature: "webhooks",
10615
+ description: "Change a webhook subscription's event filter in place: the sub_id and the delivery position are kept (nothing is replayed or skipped; the new prefixes apply from the next delivery pass). event_prefixes replaces the whole list; empty = every event. target_url is not editable \u2014 subscribe anew for a new endpoint.",
10616
+ inputSchema: {
10617
+ type: "object",
10618
+ properties: {
10619
+ sub_id: { type: "string" },
10620
+ event_prefixes: { type: "array", items: { type: "string" }, description: "the full new list, e.g. ['job.generation.', 'payments.']; [] = every event" }
10621
+ },
10622
+ required: ["sub_id", "event_prefixes"]
10623
+ },
10624
+ method: "PATCH",
10625
+ path: (a) => `/v1/webhooks/subscriptions/${encodeURIComponent(String(a.sub_id))}`
10626
+ },
9703
10627
  {
9704
10628
  name: "webhooks_unsubscribe",
9705
10629
  feature: "webhooks",
@@ -10214,12 +11138,12 @@ function lowerPath(path, inputSchema) {
10214
11138
  return path(sentinels).replace(/__([a-z0-9_]+)__/g, "{$1}");
10215
11139
  }
10216
11140
  function buildMcpCatalog(input, tools = TOOLS) {
10217
- const features = [...input.features].sort();
11141
+ const features = [...input.features].sort(compareCodePoints);
10218
11142
  const featureSet = new Set(features);
10219
- const colls = [...input.collections].sort((a, b2) => a.collection.localeCompare(b2.collection));
11143
+ const colls = canonicalCollections(input.collections);
10220
11144
  const collNames = colls.map((c) => c.collection);
10221
- const fns = [...input.functions].sort((a, b2) => a.name.localeCompare(b2.name));
10222
- const catalogTools = tools.filter((t) => !t.feature || featureSet.has(t.feature)).sort((a, b2) => a.name.localeCompare(b2.name)).map((t) => {
11145
+ const fns = [...input.functions].sort((a, b2) => compareCodePoints(a.name, b2.name));
11146
+ const catalogTools = tools.filter((t) => !t.feature || featureSet.has(t.feature)).sort((a, b2) => compareCodePoints(a.name, b2.name)).map((t) => {
10223
11147
  let inputSchema = t.inputSchema;
10224
11148
  const props = inputSchema.properties;
10225
11149
  if (collNames.length && props && "collection" in props) {
@@ -10271,10 +11195,27 @@ function buildMcpCatalog(input, tools = TOOLS) {
10271
11195
  functions: fns.map((f) => ({
10272
11196
  name: f.name,
10273
11197
  invoke: `POST /v1/fn/${f.name}`,
10274
- signature: f.signature ?? null
11198
+ // keys sorted at every depth: online reads the signature back in the
11199
+ // stored manifest's key order, offline in declaration order (F2)
11200
+ signature: f.signature ? sortKeysDeep(f.signature) : null
10275
11201
  }))
10276
11202
  };
10277
11203
  }
11204
+ var MCP_CHECK_IGNORED_KEYS = ["generated_at", "tenant", "env"];
11205
+ function mcpCatalogUpToDate(existing, catalog) {
11206
+ if (existing === null) return false;
11207
+ const strip = (o) => {
11208
+ for (const k of MCP_CHECK_IGNORED_KEYS) delete o[k];
11209
+ return canonicalJson(o);
11210
+ };
11211
+ try {
11212
+ const cur = JSON.parse(existing);
11213
+ if (!cur || typeof cur !== "object" || Array.isArray(cur)) return false;
11214
+ return strip(cur) === strip(JSON.parse(JSON.stringify(catalog)));
11215
+ } catch {
11216
+ return false;
11217
+ }
11218
+ }
10278
11219
 
10279
11220
  // src/exportUrls.ts
10280
11221
  var EXPORT_429_RETRIES = 6;
@@ -11005,96 +11946,8 @@ function devSlotExpiryCheck(dev, now) {
11005
11946
  };
11006
11947
  }
11007
11948
 
11008
- // src/link.ts
11009
- function data(r) {
11010
- return r.json.data ?? r.json;
11011
- }
11012
- function errText(r) {
11013
- const e = r.json.error;
11014
- return e ? `${e.code ?? r.status}${e.message ? ` \u2014 ${e.message}` : ""}` : `${r.status}`;
11015
- }
11016
- async function findTenantViaSession(dash, cookie, slug) {
11017
- const list = await dash("GET", "/dashboard/tenants", void 0, cookie);
11018
- if (list.status === 401) return { unauthorized: true };
11019
- if (list.status !== 200) throw new Error(`could not list projects (${errText(list)})`);
11020
- const tenants = data(list).tenants ?? [];
11021
- const t = tenants.find((x) => x.slug === slug);
11022
- if (!t) {
11023
- throw new Error(
11024
- `no project '${slug}' on this account \u2014 run \`vxil quickstart\`, or ask an admin to add you as a member`
11025
- );
11026
- }
11027
- return { tenant: t };
11028
- }
11029
- async function linkViaSession(dash, cookie, slug, keyName) {
11030
- const found = await findTenantViaSession(dash, cookie, slug);
11031
- if ("unauthorized" in found) return found;
11032
- const t = found.tenant;
11033
- const mint = await dash("POST", `/dashboard/tenants/${t.id}/api-keys`, { name: keyName }, cookie);
11034
- if (mint.status === 401) return { unauthorized: true };
11035
- if (mint.status !== 201 && mint.status !== 200) {
11036
- throw new Error(`could not mint a key for '${slug}' (${errText(mint)})`);
11037
- }
11038
- const apiKey = data(mint).api_key;
11039
- if (!apiKey) throw new Error("mint returned no api_key");
11040
- return { result: { slug, tenant_id: t.id, api_key: apiKey } };
11041
- }
11042
- async function linkViaDeviceFlow(dash, slug, io) {
11043
- const now = io.now ?? Date.now;
11044
- const start = await dash("POST", "/dashboard/cli/link", { slug });
11045
- if (start.status !== 201 && start.status !== 200) {
11046
- throw new Error(`could not start the link (${errText(start)})`);
11047
- }
11048
- const d = data(start);
11049
- if (!d.device_code || !d.user_code) throw new Error("link start returned no device_code");
11050
- io.log("");
11051
- io.log(`To link '${slug}', approve this request in your browser:`);
11052
- io.log("");
11053
- io.log(` ${d.verification_uri_complete ?? d.verification_uri ?? "(open the dashboard \u2192 CLI link)"}`);
11054
- io.log(` code: ${d.user_code}`);
11055
- io.log("");
11056
- io.log("Waiting for approval (Ctrl-C to cancel)\u2026");
11057
- const intervalMs = Math.max(1, d.interval ?? 5) * 1e3;
11058
- const deadline = now() + Math.max(1, d.expires_in ?? 900) * 1e3;
11059
- while (now() < deadline) {
11060
- await io.sleep(intervalMs);
11061
- const poll = await dash("POST", "/dashboard/cli/link/token", { device_code: d.device_code });
11062
- if (poll.status === 428) continue;
11063
- if (poll.status === 200) {
11064
- const p = data(poll);
11065
- if (!p.api_key || !p.tenant_id) throw new Error("approval returned no api_key");
11066
- return { slug: p.slug || slug, tenant_id: p.tenant_id, api_key: p.api_key };
11067
- }
11068
- throw new Error(`link failed (${errText(poll)})`);
11069
- }
11070
- throw new Error("link timed out waiting for browser approval \u2014 run `vxil link <slug>` again");
11071
- }
11072
- async function linkViaStoredKey(api, slug, storedKey) {
11073
- let res;
11074
- try {
11075
- res = await api("GET", "/v1/features");
11076
- } catch (e2) {
11077
- return { invalid: `could not reach the edge (${e2.message})` };
11078
- }
11079
- if (res.status === 200) {
11080
- const tenantId = res.body.meta?.tenant_id;
11081
- return { result: { slug, tenant_id: tenantId && tenantId.length ? tenantId : "(unknown)", api_key: storedKey } };
11082
- }
11083
- const e = res.body.error;
11084
- return { invalid: `${e?.code ?? res.status}${e?.message ? ` \u2014 ${e.message}` : ""}` };
11085
- }
11086
-
11087
11949
  // src/keys.ts
11088
11950
  var MINTABLE_SCOPES = KNOWN_SCOPES.filter((s) => s !== "admin");
11089
- function data2(r) {
11090
- return r.json.data ?? r.json;
11091
- }
11092
- function errText2(r) {
11093
- const e = r.json.error;
11094
- if (!e) return `${r.status}`;
11095
- return `${e.code ?? r.status}${e.message ? ` \u2014 ${e.message}` : ""}${e.hint ? `
11096
- hint: ${e.hint}` : ""}`;
11097
- }
11098
11951
  function parseScopes(raw) {
11099
11952
  const list = (raw ?? "").split(",").map((s) => s.trim()).filter(Boolean);
11100
11953
  if (!list.length) {
@@ -11133,9 +11986,9 @@ async function mintKeyViaSession(dash, cookie, tenantId, req) {
11133
11986
  );
11134
11987
  if (r.status === 401) return { unauthorized: true };
11135
11988
  if (r.status !== 201 && r.status !== 200) {
11136
- throw new Error(`could not mint the key (${errText2(r)})`);
11989
+ throw new Error(`could not mint the key (${errText(r)})`);
11137
11990
  }
11138
- const d = data2(r);
11991
+ const d = data(r);
11139
11992
  if (!d.api_key || !d.key_id) throw new Error("mint returned no api_key");
11140
11993
  return {
11141
11994
  result: {
@@ -11151,14 +12004,14 @@ async function mintKeyViaSession(dash, cookie, tenantId, req) {
11151
12004
  async function listKeysViaSession(dash, cookie, tenantId) {
11152
12005
  const r = await dash("GET", `/dashboard/tenants/${tenantId}/api-keys`, void 0, cookie);
11153
12006
  if (r.status === 401) return { unauthorized: true };
11154
- if (r.status !== 200) throw new Error(`could not list keys (${errText2(r)})`);
11155
- return { keys: data2(r).keys ?? [] };
12007
+ if (r.status !== 200) throw new Error(`could not list keys (${errText(r)})`);
12008
+ return { keys: data(r).keys ?? [] };
11156
12009
  }
11157
12010
  async function revokeKeyViaSession(dash, cookie, tenantId, keyId) {
11158
12011
  const r = await dash("DELETE", `/dashboard/tenants/${tenantId}/api-keys/${encodeURIComponent(keyId)}`, void 0, cookie);
11159
12012
  if (r.status === 401) return { unauthorized: true };
11160
12013
  if (r.status === 404) throw new Error(`no active key '${keyId}' on this project (already revoked, or not this project's) \u2014 \`vxil keys list\` shows the ids`);
11161
- if (r.status !== 200) throw new Error(`could not revoke the key (${errText2(r)})`);
12014
+ if (r.status !== 200) throw new Error(`could not revoke the key (${errText(r)})`);
11162
12015
  return { revoked: true };
11163
12016
  }
11164
12017
  function parseToolPermsFlags(f) {
@@ -11186,8 +12039,8 @@ async function setKeyToolPermsViaSession(dash, cookie, tenantId, keyId, patch) {
11186
12039
  const r = await dash("PATCH", `/dashboard/tenants/${tenantId}/api-keys/${encodeURIComponent(keyId)}`, patch, cookie);
11187
12040
  if (r.status === 401) return { unauthorized: true };
11188
12041
  if (r.status === 404) throw new Error(`no active key '${keyId}' on this project (revoked, or not this project's) \u2014 \`vxil keys list\` shows the ids`);
11189
- if (r.status !== 200) throw new Error(`could not update the key's tool permissions (${errText2(r)})`);
11190
- const d = data2(r);
12042
+ if (r.status !== 200) throw new Error(`could not update the key's tool permissions (${errText(r)})`);
12043
+ const d = data(r);
11191
12044
  return { key_id: d.key_id ?? keyId, allowed_tools: d.allowed_tools ?? null, denied_tools: d.denied_tools ?? null };
11192
12045
  }
11193
12046
  function renderToolPerms(p) {
@@ -11269,20 +12122,11 @@ function renderKeyTable(keys, currentKeyId) {
11269
12122
  }
11270
12123
 
11271
12124
  // src/account.ts
11272
- function data3(r) {
11273
- return r.json.data ?? r.json;
11274
- }
11275
- function errText3(r) {
11276
- const e = r.json.error;
11277
- if (!e) return `${r.status}`;
11278
- return `${e.code ?? r.status}${e.message ? ` \u2014 ${e.message}` : ""}${e.hint ? `
11279
- hint: ${e.hint}` : ""}`;
11280
- }
11281
12125
  async function showAccountViaSession(dash, cookie) {
11282
12126
  const r = await dash("GET", "/dashboard/me", void 0, cookie);
11283
12127
  if (r.status === 401) return { unauthorized: true };
11284
- if (r.status !== 200) throw new Error(`could not read the account (${errText3(r)})`);
11285
- const d = data3(r);
12128
+ if (r.status !== 200) throw new Error(`could not read the account (${errText(r)})`);
12129
+ const d = data(r);
11286
12130
  return {
11287
12131
  account: {
11288
12132
  id: String(d.id ?? ""),
@@ -11311,8 +12155,8 @@ function parseAccountPatch(f) {
11311
12155
  async function updateAccountViaSession(dash, cookie, patch) {
11312
12156
  const r = await dash("PATCH", "/dashboard/me", patch, cookie);
11313
12157
  if (r.status === 401) return { unauthorized: true };
11314
- if (r.status !== 200) throw new Error(`could not update the account (${errText3(r)})`);
11315
- const d = data3(r);
12158
+ if (r.status !== 200) throw new Error(`could not update the account (${errText(r)})`);
12159
+ const d = data(r);
11316
12160
  return { locale: d.locale ?? patch.locale ?? "en", display_name: d.display_name ?? null };
11317
12161
  }
11318
12162
  function parsePasswordStdin(raw) {
@@ -11325,19 +12169,19 @@ function parsePasswordStdin(raw) {
11325
12169
  async function changePasswordViaSession(dash, cookie, pw) {
11326
12170
  const r = await dash("POST", "/dashboard/password", { current_password: pw.current, new_password: pw.next }, cookie);
11327
12171
  if (r.status === 401) return { unauthorized: true };
11328
- if (r.status !== 200) throw new Error(`password not changed (${errText3(r)})`);
12172
+ if (r.status !== 200) throw new Error(`password not changed (${errText(r)})`);
11329
12173
  return { changed: true };
11330
12174
  }
11331
12175
  async function logoutViaSession(dash, cookie) {
11332
12176
  const r = await dash("POST", "/dashboard/logout", void 0, cookie);
11333
12177
  if (r.status >= 200 && r.status < 300) return { revoked: true };
11334
- return { revoked: false, detail: errText3(r) };
12178
+ return { revoked: false, detail: errText(r) };
11335
12179
  }
11336
12180
  async function listInvitesViaSession(dash, cookie) {
11337
12181
  const r = await dash("GET", "/dashboard/invites", void 0, cookie);
11338
12182
  if (r.status === 401) return { unauthorized: true };
11339
- if (r.status !== 200) throw new Error(`could not list invite codes (${errText3(r)})`);
11340
- const d = data3(r);
12183
+ if (r.status !== 200) throw new Error(`could not list invite codes (${errText(r)})`);
12184
+ const d = data(r);
11341
12185
  return {
11342
12186
  codes: d.codes ?? [],
11343
12187
  total: d.total ?? (d.codes ?? []).length,
@@ -11353,8 +12197,8 @@ function parseInviteCount(raw) {
11353
12197
  async function generateInvitesViaSession(dash, cookie, count) {
11354
12198
  const r = await dash("POST", "/dashboard/invites", count === void 0 ? {} : { count }, cookie);
11355
12199
  if (r.status === 401) return { unauthorized: true };
11356
- if (r.status !== 201 && r.status !== 200) throw new Error(`could not generate invite codes (${errText3(r)})`);
11357
- const d = data3(r);
12200
+ if (r.status !== 201 && r.status !== 200) throw new Error(`could not generate invite codes (${errText(r)})`);
12201
+ const d = data(r);
11358
12202
  return { codes: d.codes ?? [], count: d.count ?? (d.codes ?? []).length };
11359
12203
  }
11360
12204
  function renderAccount(a, host) {
@@ -11375,17 +12219,11 @@ function renderInvites(l) {
11375
12219
  }
11376
12220
 
11377
12221
  // src/configDrafts.ts
11378
- function errText4(r) {
11379
- const e = r.body.error;
11380
- if (!e) return `${r.status}`;
11381
- return `${e.code ?? r.status}${e.message ? ` \u2014 ${e.message}` : ""}${e.hint ? `
11382
- hint: ${e.hint}` : ""}`;
11383
- }
11384
12222
  var enc = encodeURIComponent;
11385
12223
  async function showDraft(api, feature) {
11386
12224
  const r = await api("GET", `/v1/config/${enc(feature)}/draft`);
11387
12225
  if (r.status === 404) return { none: true };
11388
- if (r.status !== 200) throw new Error(`could not read the ${feature} draft (${errText4(r)})`);
12226
+ if (r.status !== 200) throw new Error(`could not read the ${feature} draft (${apiErrText(r)})`);
11389
12227
  const d = r.body.data ?? {};
11390
12228
  const draft = {
11391
12229
  feature,
@@ -11396,7 +12234,7 @@ async function showDraft(api, feature) {
11396
12234
  };
11397
12235
  const cur = await api("GET", `/v1/config/${enc(feature)}`);
11398
12236
  if (cur.status !== 200 && cur.status !== 404) {
11399
- throw new Error(`could not read the active ${feature} config to compare (${errText4(cur)})`);
12237
+ throw new Error(`could not read the active ${feature} config to compare (${apiErrText(cur)})`);
11400
12238
  }
11401
12239
  const active = cur.status === 200 ? cur.body.data?.manifest ?? {} : {};
11402
12240
  const activeVersion = cur.status === 200 ? cur.body.data?.version ?? null : null;
@@ -11404,13 +12242,13 @@ async function showDraft(api, feature) {
11404
12242
  }
11405
12243
  async function saveDraft(api, feature, manifest) {
11406
12244
  const r = await api("PUT", `/v1/config/${enc(feature)}/draft`, manifest);
11407
- if (r.status !== 200) throw new Error(`draft not saved (${errText4(r)})`);
12245
+ if (r.status !== 200) throw new Error(`draft not saved (${apiErrText(r)})`);
11408
12246
  return { feature, manifest: r.body.data?.manifest ?? manifest };
11409
12247
  }
11410
12248
  async function publishDraft(api, feature) {
11411
12249
  const r = await api("POST", `/v1/config/${enc(feature)}/draft/publish`);
11412
12250
  if (r.status === 404) throw new Error(`no ${feature} draft to publish \u2014 \`vxil config draft save ${feature}\` stages one`);
11413
- if (r.status !== 200) throw new Error(`draft not published (${errText4(r)})`);
12251
+ if (r.status !== 200) throw new Error(`draft not published (${apiErrText(r)})`);
11414
12252
  const d = r.body.data ?? {};
11415
12253
  const apiState = d.api_state;
11416
12254
  return {
@@ -11421,7 +12259,7 @@ async function publishDraft(api, feature) {
11421
12259
  async function discardDraft(api, feature) {
11422
12260
  const r = await api("DELETE", `/v1/config/${enc(feature)}/draft`);
11423
12261
  if (r.status === 404) return { discarded: false };
11424
- if (r.status !== 200) throw new Error(`draft not discarded (${errText4(r)})`);
12262
+ if (r.status !== 200) throw new Error(`draft not discarded (${apiErrText(r)})`);
11425
12263
  return { discarded: true };
11426
12264
  }
11427
12265
  function parseVersion(raw) {
@@ -11433,7 +12271,7 @@ function parseVersion(raw) {
11433
12271
  async function duplicateToDraft(api, feature, version) {
11434
12272
  const r = await api("POST", `/v1/config/${enc(feature)}/versions/${version}/duplicate`);
11435
12273
  if (r.status === 404) throw new Error(`${feature} has no published version ${version} \u2014 \`vxil versions ${feature}\` lists them`);
11436
- if (r.status !== 200) throw new Error(`could not copy version ${version} into a draft (${errText4(r)})`);
12274
+ if (r.status !== 200) throw new Error(`could not copy version ${version} into a draft (${apiErrText(r)})`);
11437
12275
  return {
11438
12276
  feature,
11439
12277
  manifest: r.body.data?.manifest ?? {},
@@ -11467,21 +12305,15 @@ function draftFromJson(text, file) {
11467
12305
  }
11468
12306
 
11469
12307
  // src/functionsSwitch.ts
11470
- function errText5(r) {
11471
- const e = r.body.error;
11472
- if (!e) return `${r.status}`;
11473
- return `${e.code ?? r.status}${e.message ? ` \u2014 ${e.message}` : ""}${e.hint ? `
11474
- hint: ${e.hint}` : ""}`;
11475
- }
11476
12308
  async function liveFunctionNames(api) {
11477
12309
  const r = await api("GET", "/v1/functions");
11478
- if (r.status !== 200) throw new Error(`could not list the deployed functions (${errText5(r)})`);
12310
+ if (r.status !== 200) throw new Error(`could not list the deployed functions (${apiErrText(r)})`);
11479
12311
  const fns = r.body.data?.functions ?? [];
11480
12312
  return fns.map((f) => String(f.name ?? "")).filter(Boolean);
11481
12313
  }
11482
12314
  async function enableFunction(api, name) {
11483
12315
  const r = await api("POST", `/v1/functions/${encodeURIComponent(name)}/enable`);
11484
- if (r.status !== 200) throw new Error(`could not enable '${name}' (${errText5(r)})`);
12316
+ if (r.status !== 200) throw new Error(`could not enable '${name}' (${apiErrText(r)})`);
11485
12317
  const deployed = (await liveFunctionNames(api)).includes(name);
11486
12318
  return { cleared: true, deployed };
11487
12319
  }
@@ -11489,7 +12321,7 @@ async function disableFunction(api, name, opts = {}) {
11489
12321
  if (!(await liveFunctionNames(api)).includes(name)) return { wasDeployed: false };
11490
12322
  if (opts.confirm) await opts.confirm();
11491
12323
  const r = await api("DELETE", `/v1/functions/${encodeURIComponent(name)}`);
11492
- if (r.status !== 200) throw new Error(`could not disable '${name}' (${errText5(r)})`);
12324
+ if (r.status !== 200) throw new Error(`could not disable '${name}' (${apiErrText(r)})`);
11493
12325
  const v = r.body.data?.version;
11494
12326
  return { wasDeployed: true, version: typeof v === "number" ? v : null };
11495
12327
  }
@@ -12834,9 +13666,9 @@ function buildItem(row2, mapping, state, tableByCollection, resolve8) {
12834
13666
  }
12835
13667
  function holdsContent(stored, item, storageTargets) {
12836
13668
  if (stored === null || typeof stored !== "object" || Array.isArray(stored)) return false;
12837
- const data4 = stored;
13669
+ const data2 = stored;
12838
13670
  return Object.entries(item.full).every(([k, v]) => {
12839
- const cur = rowHash(data4[k] ?? null);
13671
+ const cur = rowHash(data2[k] ?? null);
12840
13672
  return cur === rowHash(v) || storageTargets.has(k) && cur === rowHash(item.hashInput[k]);
12841
13673
  });
12842
13674
  }
@@ -12927,8 +13759,8 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
12927
13759
  if (holdsContent(stored, item, storageTargets)) {
12928
13760
  hashes[sourceId] = h;
12929
13761
  const { patch } = storagePatch(row2, storageFields, resolve8);
12930
- const data4 = stored;
12931
- if (Object.entries(patch).every(([k, oid]) => data4[k] === oid)) noteStorage(sourceId, row2);
13762
+ const data2 = stored;
13763
+ if (Object.entries(patch).every(([k, oid]) => data2[k] === oid)) noteStorage(sourceId, row2);
12932
13764
  unhashedSame++;
12933
13765
  result.skipped++;
12934
13766
  continue;
@@ -15859,8 +16691,8 @@ export default defineConfig({
15859
16691
  ],
15860
16692
  "hasFunctions": true,
15861
16693
  "byoKeys": [],
15862
- "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// \"Funnel SaaS\" \u2014 the STUDIO STARTER. A web-first funnel product, declared\n// end-to-end in ONE typed file:\n//\n// guest session \u2192 guided steps \u2192 e-mail claim \u2192 account \u2192 pass \u2192 AI brief\n//\n// What it composes (every block is a shipped feature; nothing here is a\n// platform primitive you would have to build):\n// \u2022 auth \u2192 anonymous sign-in for the visitor, an OTP e-mail claim that\n// PROMOTES the guest in place (same id, same rows), a session\n// pair the server refreshes behind a long-lived cookie\n// \u2022 cms \u2192 two owner-scoped collections behind `strictEndUserScope`:\n// a signed-in visitor sees ONLY their own rows, and a\n// collection without an owner field is server-only\n// \u2022 rate-limits \u2192 two policies DECLARED here, converged by `vxil push`\n// \u2022 webhooks \u2192 failure alerts to the owners + ONE declared outbound\n// subscription for your ops endpoint\n// \u2022 ai \u2192 a stored prompt template DECLARED here; the brief runs on\n// the job lane with a `correlation_id` = the session row id\n// \u2022 payments \u2192 a payments INTEGRATION: your own provider account (mock\n// here), vxil never in the flow of funds\n// \u2022 functions \u2192 three event-driven functions (below)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n auth: {\n // Visitors never see a password form: a guest session first, an e-mail\n // code to claim it later (magic link stays on as the alternative).\n methods: { emailPassword: false, magicLink: true },\n anonymous: { enabled: true },\n // The claim rides the OTP machinery (POST /v1/auth/anonymous/link/request\n // + /verify). `testRecipients` is where your CI address goes \u2014 a listed\n // address gets no mail and the 202 carries `test_code`.\n otp: { enabled: true, codeTtlMinutes: 10, maxAttempts: 5, resendCooldownSec: 60 },\n // The pair your server middleware rotates (README: \"keep the token fresh\").\n session: { ttlMinutes: 60, refreshTtlDays: 30 },\n },\n\n cms: {\n // A funnel row is live the moment it is written \u2014 no editorial draft step.\n draftPublish: false,\n // THE FAIL-SAFE. In end-user mode a collection with no `ownerField` is\n // server-only (403 server_only on read AND write) instead of\n // tenant-wide-shared. Every collection below declares an owner, so a\n // visitor can only ever reach their own rows; a collection you add later\n // without one is closed to them until you say otherwise.\n strictEndUserScope: true,\n hooks: {\n // Steps only move forward \u2014 a client cannot rewind a completed intake.\n // (`coalesce` so a row created without a step can still be stepped.)\n steps_forward_only: {\n collection: 'funnel_sessions',\n event: 'beforeUpdate',\n kind: 'validate',\n expr: 'coalesce(item.step, 0) >= coalesce(before.step, 0)',\n message: 'steps only move forward',\n },\n // The session state machine: in_progress \u2192 completed, and completed is\n // terminal (the brief function flips it; nothing flips it back).\n session_transition: {\n collection: 'funnel_sessions',\n event: 'beforeUpdate',\n kind: 'validate',\n expr: \"item.status == before.status || (before.status == 'in_progress' && item.status == 'completed')\",\n message: 'illegal session status transition',\n },\n // A pass is bound to the charge that paid for it, forever.\n pass_charge_immutable: {\n collection: 'passes',\n event: 'beforeUpdate',\n kind: 'validate',\n expr: 'item.charge_id == before.charge_id',\n message: 'a pass keeps the charge that paid for it',\n },\n },\n },\n\n // Auth mail (the claim code) and the \"your brief is ready\" message ride\n // this. `mock` needs no account; point it at your own provider to go live.\n notifications: { provider: 'mock', fromEmail: 'hello@studio.example' },\n\n // DECLARED API STATE #1 \u2014 rate-limit policies. These used to be rows you\n // created by hand with POST /v1/rate-limits/policies and re-created in every\n // environment; now `vxil push` converges the live set onto this list by\n // name (creates what is missing, patches what differs, REPORTS what you did\n // not declare). Your server checks them with POST /v1/rate-limits/check.\n 'rate-limits': {\n enabled: true,\n policies: [\n // one visitor, thirty step writes a minute \u2014 enough for a human, not a loop\n { name: 'funnel-step', key_template: '{tenant_id}:{user_id}', limit: 30, window_seconds: 60 },\n // three claim codes per address per ten minutes; pass a HASH of the\n // address as `email_hash`, never the address itself (it becomes a key)\n { name: 'claim-request', key_template: '{tenant_id}:{email_hash}', limit: 3, window_seconds: 600, behavior: 'block' },\n ],\n },\n\n webhooks: {\n enabled: true,\n // FAILURE ALERTS. Every error-level failure event in this backend (a dead\n // delivery, a failed generation, a missed schedule) is digested into a\n // mail to the project's owner accounts every 15 minutes. Recipients are\n // deliberately not configurable; `digestMinutes: 0` makes it immediate.\n alerts: { enabled: true, minLevel: 'error', digestMinutes: 15 },\n // DECLARED API STATE #2 \u2014 an outbound subscription. Converged by\n // target_url on push; a live row you did not declare is left and\n // reported. The three function triggers below are NOT declared here \u2014\n // their subscriptions derive from the functions manifest.\n subscriptions: [\n { target_url: 'https://ops.studio.example/vxil/events', event_prefixes: ['payments.', 'auth.user.'] },\n ],\n },\n\n ai: {\n enabled: true,\n defaultProvider: 'mock', // BYO key later: providers.openaiKeyRef / anthropicKeyRef / geminiKeyRef\n defaults: { maxTokens: 800, temperature: 0.4 },\n // DECLARED API STATE #3 \u2014 the stored prompt template. Converged by\n // CONTENT: a push stores a new version only when the text changed, so\n // pushes are idempotent and versions stay monotonic. vxil stores and\n // renders it; the wording is yours.\n templates: [\n {\n template: 'studio-brief',\n system:\n 'You are a senior brand strategist. Write in plain, confident English. ' +\n 'Never invent facts about the client; when something is unknown, say what you would need.',\n user:\n 'Write a one-page creative brief for a studio client.\\n\\n' +\n 'Goal: {{goal}}\\nAudience: {{audience}}\\nTone: {{tone}}\\nConstraints: {{constraints}}\\n\\n' +\n 'Sections: Objective, Audience insight, Key message, Deliverables, Next steps.',\n },\n ],\n },\n\n // The job lane the brief runs on. `retry` is the ladder for JOB RUNS \u2014 the\n // generation itself, and deliveries to your own endpoints. It is NOT a\n // retry of what a platform-delivered function ANSWERS: the dispatcher ACKs\n // every trigger delivery with a 200 whatever the function returned (guide\n // 08, \"What is retried, and what is not\"), so the three functions below\n // make their failures recoverable through the rows they own \u2014 a 5xx from\n // one of them is observed (`functions.run.failed` \u2192 `webhooks.alerts`),\n // never re-delivered.\n jobs: { enabled: true, retry: { defaultMaxAttempts: 3 } },\n\n // A payments INTEGRATION: the tenant's own Stripe/Paddle/PayPal/RevenueCat\n // account; vxil is never in the flow of funds. `mock` needs no account and\n // runs everything UP TO the paywall \u2014 push, deploy, the intake, the claim\n // and every refusal (402 pass_required, 409 intake_incomplete). It cannot\n // put a PAID charge on the ledger: its checkout URL is a placeholder, its\n // webhooks are signed with the platform's secret, and\n // `vx.payments.simulate` scripts subscriptions, not one-off charges \u2014 so on\n // `mock` grant-pass never wakes and start-brief answers 402 to everyone.\n // The paid path needs your provider's sandbox (README, \"Running the paid\n // path\"), e.g. a Stripe test-mode account:\n // payments: { enabled: true, provider: 'stripe', defaults: { currency: 'usd' },\n // stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' } },\n payments: { enabled: true, provider: 'mock', defaults: { currency: 'usd' } },\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 // One row per visitor intake. Created and stepped by the browser as the\n // (guest, then claimed) end-user; completed and settled by functions.\n funnel_sessions: {\n singular: 'funnel_session',\n // The owner. In end-user mode the platform STAMPS this with the verified\n // user id on create and scopes every read/patch/delete to it \u2014 a body\n // naming someone else is a 400, and a foreign row is an honest 404.\n ownerField: 'user',\n fields: {\n user: { type: 'string', required: true, indexSlot: 's1' },\n status: { type: 'string', indexSlot: 's2', validation: { enum: ['in_progress', 'completed'] } },\n generation_id: { type: 'string', indexSlot: 's3' }, // the job-lane generation that owns the result\n result_status: { type: 'string', indexSlot: 's4', validation: { enum: ['queued', 'completed', 'failed'] } },\n step: { type: 'int', indexSlot: 'n1', validation: { min: 1, max: 4 } }, // 1..4, forward only\n started_at: { type: 'datetime', indexSlot: 't1' },\n completed_at: { type: 'datetime', indexSlot: 't2' },\n answers: { type: 'json' }, // { goal, audience, tone, constraints } \u2014 the template's inputs\n result: { type: 'text' }, // the settled brief\n error_hint: { type: 'string' }, // the provider's own words when the brief failed\n },\n },\n\n // One row per PAID pass, written by the grant function on the provider's\n // charge event. `charge_id` is the dedupe anchor: an at-least-once\n // redelivery of the same charge is a clean 409 the function treats as\n // \"already granted\". It is the DISPLAY RECORD, never the entitlement: the\n // collection is owned, so the visitor's own thin-client key (cms:write)\n // can create a pass or stretch its expires_at \u2014 start-brief decides from\n // the owner-bound payments ledger instead.\n passes: {\n singular: 'pass',\n ownerField: 'user',\n fields: {\n user: { type: 'string', required: true, indexSlot: 's1' },\n charge_id: { type: 'string', required: true, unique: true, indexSlot: 's2' },\n product: { type: 'string', indexSlot: 's3' },\n amount_cents: { type: 'int', indexSlot: 'n1' },\n granted_at: { type: 'datetime', indexSlot: 't1' },\n expires_at: { type: 'datetime', indexSlot: 't2' }, // the brief function's range filter\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: three event-driven functions \u2500\u2500\u2500\u2500\u2500\u2500\n functions: {\n // THE BRIEF. Invoked by the browser AS THE SIGNED-IN VISITOR\n // (vx.asEndUser(token).fn['start-brief']({ session_id })): the end-user\n // principal rides into the function, so its cms reads are owner-scoped and\n // the generation is billed to that user. Requires a PAID pass, read from\n // the owner-bound ledger (`payments:read`) on every call \u2014 never from the\n // visitor-writable `passes` row, which it re-writes as the record when a\n // grant delivery was lost; CLAIMS the row with If-Match before spending\n // anything (a double click is one generation);\n // starts the brief on the ai JOB lane with correlation_id = the session row id.\n 'start-brief': {\n entry: './functions/start-brief.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'ai:write', 'payments:read'],\n egressAllow: [],\n signature: {\n input: { session_id: 'string' },\n // Two 202 answers, so the output is a whole TS type (a union): a new\n // brief's handle, or `already_queued` when one is running \u2014 whose\n // generation_id is null while that claim has not recorded its handle.\n output:\n '{ generation_id: string; run_id: string; correlation_id: string }' +\n \" | { generation_id: string | null; status: 'already_queued' }\",\n },\n },\n\n // THE GRANT. Woken by ONE platform event \u2014 `payments.charge.succeeded`, the\n // full event name as the `source` \u2014 a few seconds after your provider's\n // webhook folds. Re-reads the charge from the ledger (the event name is a\n // routing claim, not an attestation), then writes the pass. A 5xx from it\n // is ACKed and observed, not re-delivered \u2014 start-brief decides from the\n // same ledger, so a lost grant never blocks a payer.\n 'grant-pass': {\n entry: './functions/grant-pass.ts',\n trigger: { kind: 'webhook', source: 'payments.charge.succeeded' },\n scopes: ['payments:read', 'cms:write'],\n egressAllow: [],\n },\n\n // THE SETTLE. Woken by the `job.generation.` prefix \u2014 completed AND failed \u2014\n // and finds its own row through the event's `correlation_id` (no run\u2192record\n // table of your own). On completed: the brief text from the replay buffer\n // + a \"ready\" message. On failed: `error_hint` \u2014 the provider's own words \u2014\n // onto the row, so the visitor sees why, not just that. A settle that\n // cannot complete marks the row `failed` (the retry is one click) \u2014 the\n // delivery is ACKed whatever it answers, so recovery lives in the row.\n 'settle-brief': {\n entry: './functions/settle-brief.ts',\n trigger: { kind: 'webhook', source: 'job.generation.' },\n scopes: ['cms:read', 'cms:write', 'ai:read', 'notifications:send'],\n egressAllow: [],\n },\n },\n});\n",
15863
- "readme": "# Funnel SaaS \u2014 the studio starter (saas)\n\nThe **web-first funnel** blueprint: a visitor lands, starts as a **guest**, walks a guided intake,\n**claims** the session with an e-mail code, buys a **pass** through your own payments provider,\nand gets an AI-written brief **settled back onto their record** \u2014 all of it declared in one typed\n`vxil.config.ts`, with three small functions for the parts that are code.\n\nIt is the shape most \"studio\" products have \u2014 a lead magnet, a paid deliverable, an account that\nonly exists once someone cares \u2014 and it is also the blueprint that shows **declared API state**:\nrate-limit policies, an outbound subscription and a stored prompt template live in the config and\nconverge on `vxil push`, so a second environment is a push, not a runbook.\n\n```bash\nvxil init --template funnel-saas\nvxil quickstart # or `vxil link <slug>` for an existing backend\nvxil push # collections + hooks + the three functions + the declared API state\nvxil gen # typed SDK: vx.from('funnel_sessions'), vx.fn['start-brief']\n```\n\n**What it provisions:**\n- `funnel_sessions` \u2014 `user` (the **owner field**), `status` (`in_progress \u2192 completed`, hook-guarded),\n `step` (1\u20134, forward only), `answers` (json), then the brief's `generation_id`, `result_status`\n (`queued | completed | failed`), `result` and `error_hint`.\n- `passes` \u2014 `user` (owner), `charge_id` (**unique** \u2014 the dedupe anchor), `product`, `amount_cents`,\n `granted_at`, `expires_at`. The record the page shows \u2014 the payments ledger is the entitlement.\n- Features: `auth` (guest + OTP claim + a refreshable session pair) \xB7 `cms` (**`strictEndUserScope: true`**) \xB7\n `rate-limits` (two **declared policies**) \xB7 `webhooks` (**`alerts` on** + one **declared subscription**) \xB7\n `ai` (one **declared template**) \xB7 `jobs` \xB7 `payments` (a payments **integration** \u2014 your provider; `mock`\n here, which runs everything *up to* the paywall \u2014 [Running the paid path](#running-the-paid-path)) \xB7\n `notifications` (`mock`) \xB7 `functions`.\n- Functions: `start-brief` (http, invoked as the visitor), `grant-pass` (webhook on\n `payments.charge.succeeded`), `settle-brief` (webhook on the `job.generation.` prefix).\n\n## The funnel, step by step\n\nEvery call below is the browser talking to your backend \u2014 with **two keys**, the way\nvxil.com/docs/guide/09-security-and-multitenancy prescribes: a **sign-in key** (the ordinary class,\ncarrying only `auth:signin`) for the moment *before* there is a session, and a **thin-client key**\n(`key_class: 'public'` \u2014 `cms:read`, `cms:write`, `functions:invoke`) for everything after, which the\nedge refuses unless it rides with a valid end-user token. A public key *cannot* carry `payments:write`\n(a `422` at mint), so the checkout is the one call your server makes \u2014 the last section.\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nconst signin = Vxil.connect({ apiKey: SIGNIN_KEY }); // auth:signin only \u2014 works with no session\nconst thin = Vxil.connect({ apiKey: THIN_CLIENT_KEY }); // public class \u2014 needs a session on every call\n\n// 1. A guest. Real user id, real session, no e-mail yet (the token carries `anon: true`).\nconst guest = await signin.auth.anonymous.signIn();\nconst me = thin.asEndUser(guest.session.token);\n\n// 2. The intake. Owner-scoped: the platform stamps `user` with the guest's id on create,\n// and this session (and only this session) can read and step the row.\nconst { item_id: session_id } = await me.from('funnel_sessions').create({\n status: 'in_progress', step: 1, started_at: new Date().toISOString(),\n answers: { goal: 'Launch a coffee subscription' },\n});\nawait me.from('funnel_sessions').patch(session_id, { step: 2, answers: { goal: '\u2026', audience: '\u2026' } });\n// \u2026steps 3 and 4. A PATCH that moves `step` backwards is a 422 from the `steps_forward_only` hook.\n\n// 3. The claim. A 6-digit code proves the address; the guest is PROMOTED IN PLACE \u2014\n// same user id, so the intake rows are already theirs. (Sign-in class: the sign-in key.)\nawait signin.auth.anonymous.link.request({ token: guest.session.token, email: 'ada@example.com' });\nconst claimed = await signin.auth.anonymous.link.verify({ token: guest.session.token, email: 'ada@example.com', code: '123456' });\n// claimed.user_id === guest.user_id \u2014 unless the address already had an account: then\n// `merged: true`, a fresh session, and `rekeyed` says whether the guest's rows moved already.\n\n// 4. The pass \u2014 only after the claim. Your SERVER refuses a guest, creates the hosted checkout\n// (below) and hands the URL back; the provider's webhook folds the charge \u2192\n// `payments.charge.succeeded` \u2192 grant-pass writes the pass record.\n\n// 5. The brief. Invoked AS THE VISITOR: the function's reads are owner-scoped and the\n// generation is metered to them. Until the visitor's ledger holds a paid charge the call\n// throws a VxilError whose `.code` is 'pass_required' (402); an unfinished intake is\n// 'intake_incomplete' (409).\nconst handle = await me.fn['start-brief']({ session_id });\n// \u2192 { generation_id, run_id, correlation_id } for a new brief, or \u2014 a second click while one\n// is running \u2014 { generation_id, status: 'already_queued' }: one generation, metered once,\n// and `generation_id` is null while that attempt has not recorded its handle yet (the\n// declared signature is that union). Either way, watch the ROW, not the handle:\n// \u2026seconds to minutes later, settle-brief writes `result` (or `error_hint`) onto the row and\n// notifications delivers \"Your brief is ready\". A failed brief can be started again: the\n// same call on a row whose result_status is 'failed' queues a new generation.\n```\n\n## What to learn from this\n\n- **`strictEndUserScope: true` is the fail-safe version of owner scoping.** Both collections declare an\n `ownerField`, so a signed-in visitor reaches exactly their own rows; a collection you add later *without*\n one is `403 server_only` for end-users on read and write instead of tenant-wide-shared. Nothing about a\n server call changes \u2014 a server key still sees everything\n (vxil.com/docs/guide/09-security-and-multitenancy).\n- **A guest is a real end-user.** `vx.auth.anonymous.signIn()` mints a real id; owner-scoped rows written\n before the claim stay attached because the OTP claim (`POST /v1/auth/anonymous/link/request` + `/verify`)\n promotes the guest in place. Passing the guest's token as `anonymous_token` on a magic-link request does\n the same through mail (vxil.com/docs/guide/06-feature-catalog).\n- **Declared API state.** Three things that used to be rows you created by hand \u2014 and re-created in every\n environment \u2014 are config now, and `vxil push` converges the live rows onto them, reporting (never\n deleting) what you did not declare:\n\n | Block | Converged by | The live rows it replaces |\n |---|---|---|\n | `features['rate-limits'].policies[]` | `name` | `POST /v1/rate-limits/policies` |\n | `features.webhooks.subscriptions[]` | `target_url` | `POST /v1/webhooks/subscriptions` |\n | `features.ai.templates[]` | content hash | `POST /v1/ai/templates` (a new version only when the text changed) |\n\n The three function triggers are *not* declared here \u2014 a `webhook`-trigger binding on a function is its\n own declaration, and the platform reconciles that subscription from the functions manifest.\n- **An event-driven grant, done honestly.** `grant-pass` wakes on the full event name\n `payments.charge.succeeded` a few seconds after your provider's webhook folds. The event name is a\n *routing claim*, so the function re-reads the ledger (`GET /v1/payments/charges`) before writing; the\n `passes.charge_id` unique field (the ledger's charge id) turns an at-least-once redelivery into a `409`\n it reads as \"already granted\". And it is honest about **what is not retried**: the dispatcher ACKs a\n platform-delivered function with a `200` *whatever it answered*, so a `5xx` from the API is never\n re-delivered \u2014 it is *observed* (`functions.run.failed`, once per failure streak, in the\n `webhooks.alerts` digest), and recovery is data, not delivery: `start-brief` decides from the same\n owner-bound ledger on every request, so a lost grant never blocks a payer, and it writes the missing\n pass record for that charge (a `409` there just means it is recorded already)\n (vxil.com/docs/guide/08-running-your-code-functions).\n- **An owned row the visitor can write is never an entitlement.** `passes` declares an `ownerField`, so\n the thin-client key (`cms:write`) lets a visitor *create* a pass of their own or PATCH its\n `expires_at` to 2099 \u2014 the platform stamps them as the owner, which proves who wrote the row, not that\n anyone paid, and a write hook's `caller` is always `null`, so no hook can tell that write from\n `grant-pass`'s (vxil.com/docs/guide/07-validation-and-hooks). So `start-brief` never reads `passes` to\n decide: on every call it reads `GET /v1/payments/charges?status=succeeded` as the visitor (bound to\n the verified principal; no key a browser holds can write the ledger) and needs a succeeded, unrefunded\n charge from the last 365 days. The pass row is what the page *shows*; the ledger is what it *checks*.\n- **`correlation_id` closes the loop.** `start-brief` sends the session row id as `correlation_id` on\n `POST /v1/ai/generate` (`mode: 'job'`); the platform echoes it on `job.generation.completed` /\n `job.generation.failed` next to `generation_id`, so `settle-brief` finds its own row from the event alone.\n On `failed` it writes **`error_hint`** \u2014 the provider's own status and message \u2014 onto the row, so the\n visitor sees *why* (and `start-brief` accepts that row again, so a retry is one more click); on\n `completed` the text comes from the generation's replay buffer\n (`GET /v1/ai/generations/{generation_id}/stream?since=0`). Every settle write carries\n `if: { generation_id }`, so a late delivery for an earlier attempt can never overwrite a newer one; and a\n settle that cannot complete marks the row `failed` with `settle failed: <reason>` instead of answering a\n status nothing will act on \u2014 the row, not the delivery, is where recovery lives.\n- **The claim comes before the spend.** `start-brief` reads the row's `version`, PATCHes\n `result_status: 'queued'` with `If-Match: <version>`, and only then calls generate. A double click that\n lands inside the generate round-trip loses the compare-and-set with `409 version_conflict` and gets a\n `202 already_queued` \u2014 one generation, one credit reserve. A claim whose generate never recorded, or a\n queued handle older than the job lane's ceiling, is treated as failed on the next click, so a row can\n never stick in `queued`. Every write after the claim (recording the handle, marking a failure) carries\n `If-Match: <the claim's version>`, so an attempt the next click already swept can never overwrite the\n newer claim \u2014 its `409` means \"the row moved on\", and it steps aside without a write.\n- **Function errors are coded.** Every refusal a function answers is `{ error: { code, message } }` \u2014\n `POST /v1/fn/:name` hands that body back verbatim, and the SDK raises it as a `VxilError` whose `.code`\n is the function's own (`pass_required`, `intake_incomplete`, `already_settled`), so a page branches on a\n code, never on a message. The platform-delivered functions answer `200 { skipped }` for a decision, and a\n `500` with a code (`unsettled`, `grant_unsettled`) only when not even the row could record the failure \u2014\n not to be retried (nothing re-delivers it) but to be *seen* through `functions.run.failed`.\n- **Failure alerts are on.** `webhooks.alerts` mails the project's owner accounts every 15 minutes with the\n error-level failure events \u2014 a dead delivery, a failed generation, a missed schedule. Recipients are\n deliberately not configurable; `digestMinutes: 0` makes it immediate.\n- **The payments feature is an integration.** Your Stripe, Paddle, PayPal or RevenueCat account, the\n provider's hosted checkout, the provider's webhook; vxil folds the events and keeps the ledger. The\n scaffold's `mock` provider stops at the paywall, so the paid path runs on your provider's sandbox \u2014 the\n next section. To rehearse the grant chain without a card, Paddle's simulator signs with your real endpoint\n secret and your `payments.` functions run for real (vxil.com/docs/guide/06-feature-catalog).\n\n## Server side: keep the visitor's token fresh\n\nA session token lives `session.ttlMinutes` (60 here); the cookie your app sets lives days. Every sign-in\nreturns a pair \u2014 the token and a `refresh_token` good for `session.refreshTtlDays` (30) \u2014 and\n`POST /v1/auth/sessions/refresh` rotates it. So a server-rendered app needs one root middleware, not a\nredesign (vxil.com/docs/guide/09-security-and-multitenancy):\n\n```ts\n// once per request, before any loader runs\nconst { token, refresh_token, expires_at } = readSessionCookie(request);\nlet session = { token, refresh_token, expires_at };\nif (Date.parse(expires_at) - Date.now() < 5 * 60_000) { // within 5 min of expiry (or past it)\n try {\n session = (await vx.auth.sessions.refresh(refresh_token)).session; // rotated pair\n setSessionCookie(response, session); // store BOTH halves again\n } catch {\n clearSessionCookie(response); // refresh revoked or expired: sign in again\n }\n}\nconst asUser = vx.asEndUser(session.token); // every per-visitor read below is scoped by the platform\n```\n\nStore the refresh token only in the `HttpOnly` cookie; refresh *early* rather than on `401`, so parallel\nloaders do not race to rotate the same token; keep the server key for the tenant-wide rows only.\n\nThe same server owns the **checkout** \u2014 `POST /v1/payments/checkout-sessions` needs `payments:write`, which\na public key cannot carry, and it is server-only (a `403 server_only` in end-user mode). Three rules shape it:\n\n- **The buyer is the session, never a form field.** Ask the platform whose token it is:\n `vx.auth.sessions.verify` answers with the account *as it is now* (`auth:read` on the server key).\n- **A guest cannot buy \u2014 claim first.** A charge stays with the user id that paid it. When a guest claims an\n address that already has an account, the merge re-keys the guest's subscriptions, customer links and\n credit balances onto that account (vxil.com/docs/guide/06-feature-catalog, \"What a merge moves\") \u2014 not its\n one-off charges \u2014 and `start-brief` decides from the charges ledger. A guest who paid and *then* merged\n would have paid for a brief the merged account can never start. So refuse the checkout until the claim:\n `verified` is `false` while the visitor has no proven address \u2014 a guest has none, and the claim *is* the\n proof (every other way into this blueprint, a magic link or a code, proves one too). Not the token's\n `anon` claim: that is a snapshot from when the token was minted, not the account as it is now.\n- **One Idempotency-Key per purchase *attempt*, never per visitor.** A key the payments feature has seen\n replays its first session verbatim, and the key never expires \u2014 so `pass:${user_id}` would hand a visitor\n whose checkout expired, who was refunded, or who comes back after the 365-day pass the same dead URL\n forever. Key the attempt: the buy page renders a fresh attempt id into its form, so a double click posts\n the same id (one session), while a reload, a return from the checkout or next year's renewal renders a\n new one. The id is not a credential \u2014 the `user_id` next to it is the verified one, so an invented id\n only ever opens another checkout for the visitor's own account.\n\n```ts\n// the visitor is signed in (the middleware above keeps `session` fresh); the form posts `attempt`\nconst who = await vx.auth.sessions.verify(session.token); // { user_id, verified, \u2026 } \u2014 the account NOW\nif (!who.verified) { // still a guest: charges never follow a merge\n return Response.json({ error: { code: 'claim_required', message: 'claim your e-mail before you buy' } }, { status: 409 });\n}\nconst attempt = String(form.get('attempt') ?? ''); // crypto.randomUUID(), rendered with the buy page\nif (!/^[\\w-]{16,64}$/.test(attempt)) {\n return Response.json({ error: { code: 'bad_attempt', message: 'reload the page and try again' } }, { status: 400 });\n}\nconst { url } = await vx.payments.createCheckoutSession(\n { user_id: who.user_id, mode: 'payment',\n line_items: [{ price_ref: 'studio-pass', amount_cents: 4900 }], // a real provider takes ITS price id \u2014 below\n success_url: 'https://studio.example/thanks', cancel_url: 'https://studio.example/pass' },\n { idempotencyKey: `pass:${who.user_id}:${attempt}` },\n);\n// redirect the visitor to `url` \u2014 a hosted checkout on YOUR provider; vxil is never in the flow of funds.\n```\n\nThe same server is where the declared rate-limit policies are checked. `POST /v1/rate-limits/check` takes a\n`policy_id`, never a name \u2014 list the policies once after the first push and keep the two ids:\n\n```ts\nconst policies = await vx.rateLimits.listPolicies(); // RateLimitPolicy[] \u2014 \u2264100, no cursor\nconst stepPolicy = policies.find((p) => p.name === 'funnel-step')!.policy_id;\n// before forwarding a step write\nawait vx.rateLimits.check({ policy_id: stepPolicy, key_values: { user_id }, cost: 1 }); // 429 + Retry-After when blocked\n// before forwarding a claim request \u2014 a HASH of the address, never the address (it becomes a key)\nawait vx.rateLimits.check({ policy_id: claimPolicy, key_values: { email_hash: sha256(email) } });\n```\n\n## Running the paid path\n\nThe scaffold ships `payments.provider: 'mock'`, so `vxil push` works with no provider account, and\neverything but the payment runs on it: the intake, the claim, the deploy, and every refusal\n(`402 pass_required`, `409 intake_incomplete`, a guest's `claim_required`). What `mock` cannot do is put a\n**paid** charge on the ledger: its checkout URL is a placeholder host nothing serves, its webhooks are\nsigned with the platform's own secret rather than one you hold, and `vx.payments.simulate` scripts\n*subscription* lifecycles, not a one-off charge. So on `mock`, `grant-pass` never wakes and `start-brief`\nanswers `402 pass_required` to every visitor \u2014 the paywall doing its job. To run the brief end to end,\npoint the block at your provider's sandbox, in a project of its own (the signing-secret ref is one per\nprovider \u2014 vxil.com/docs/guide/06-feature-catalog):\n\n```ts\n// vxil.config.ts \u2014 a Stripe TEST-mode account (a Paddle sandbox is `provider: 'paddle'` + `paddle: { \u2026 }`)\npayments: {\n enabled: true, provider: 'stripe', defaults: { currency: 'usd' },\n stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' },\n},\n```\n\n```bash\nvxil secrets set payments/stripe_secret # the test-mode secret key, at the hidden prompt\nvxil secrets set payments/stripe_webhook # the signing secret of the endpoint below\nvxil push\n```\n\nRegister `https://api.vxil.com/v1/internal/payments/webhook/stripe/<tenantId>` as the endpoint in the\nprovider's dashboard. On a real provider `line_items[].price_ref` is the provider's own price id (Stripe\n`price_\u2026`), not `studio-pass`. Pay with the provider's test card: the charge folds \u2192\n`payments.charge.succeeded` \u2192 `grant-pass` records the pass, and the next `start-brief` queues the brief.\n\n**Go deeper:** vxil.com/docs/guide/09-security-and-multitenancy (end-user mode \xB7 `strictEndUserScope` \xB7 the\nrefresh middleware) \xB7 vxil.com/docs/guide/06-feature-catalog (auth guests and claims \xB7 rate-limits \xB7\nwebhooks alerts and declared subscriptions \xB7 ai `mode: 'job'` \xB7 payments) \xB7\nvxil.com/docs/guide/08-running-your-code-functions (the `webhook` trigger \xB7 at-least-once) \xB7\nvxil.com/docs/guide/04-data-with-cms (owner scoping \xB7 the query DSL).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
16694
+ "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// \"Funnel SaaS\" \u2014 the STUDIO STARTER. A web-first funnel product, declared\n// end-to-end in ONE typed file:\n//\n// guest session \u2192 guided steps \u2192 e-mail claim \u2192 account \u2192 pass \u2192 AI brief\n//\n// What it composes (every block is a shipped feature; nothing here is a\n// platform primitive you would have to build):\n// \u2022 auth \u2192 anonymous sign-in for the visitor, an OTP e-mail claim that\n// PROMOTES the guest in place (same id, same rows), a session\n// pair the server refreshes behind a long-lived cookie\n// \u2022 cms \u2192 two owner-scoped collections behind `strictEndUserScope`:\n// a signed-in visitor sees ONLY their own rows, and a\n// collection without an owner field is server-only\n// \u2022 rate-limits \u2192 two policies DECLARED here, converged by `vxil push`\n// \u2022 webhooks \u2192 failure alerts to the owners + ONE declared outbound\n// subscription for your ops endpoint\n// \u2022 ai \u2192 a stored prompt template DECLARED here; the brief runs on\n// the job lane with a `correlation_id` = the session row id\n// \u2022 payments \u2192 a payments INTEGRATION: your own provider account (mock\n// here), vxil never in the flow of funds\n// \u2022 functions \u2192 three event-driven functions (below)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n auth: {\n // Visitors never see a password form: a guest session first, an e-mail\n // code to claim it later (magic link stays on as the alternative).\n methods: { emailPassword: false, magicLink: true },\n anonymous: { enabled: true },\n // The claim rides the OTP machinery (POST /v1/auth/anonymous/link/request\n // + /verify). `testRecipients` is where your CI address goes \u2014 a listed\n // address gets no mail and the 202 carries `test_code`.\n otp: { enabled: true, codeTtlMinutes: 10, maxAttempts: 5, resendCooldownSec: 60 },\n // The pair your server middleware rotates (README: \"keep the token fresh\").\n session: { ttlMinutes: 60, refreshTtlDays: 30 },\n },\n\n cms: {\n // A funnel row is live the moment it is written \u2014 no editorial draft step.\n draftPublish: false,\n // THE FAIL-SAFE. In end-user mode a collection with no `ownerField` is\n // server-only (403 server_only on read AND write) instead of\n // tenant-wide-shared. Every collection below declares an owner, so a\n // visitor can only ever reach their own rows; a collection you add later\n // without one is closed to them until you say otherwise.\n strictEndUserScope: true,\n hooks: {\n // Steps only move forward \u2014 a client cannot rewind a completed intake.\n // (`coalesce` so a row created without a step can still be stepped.)\n steps_forward_only: {\n collection: 'funnel_sessions',\n event: 'beforeUpdate',\n kind: 'validate',\n expr: 'coalesce(item.step, 0) >= coalesce(before.step, 0)',\n message: 'steps only move forward',\n },\n // The session state machine: in_progress \u2192 completed, and completed is\n // terminal (the brief function flips it; nothing flips it back).\n session_transition: {\n collection: 'funnel_sessions',\n event: 'beforeUpdate',\n kind: 'validate',\n expr: \"item.status == before.status || (before.status == 'in_progress' && item.status == 'completed')\",\n message: 'illegal session status transition',\n },\n // A pass is bound to the charge that paid for it, forever.\n pass_charge_immutable: {\n collection: 'passes',\n event: 'beforeUpdate',\n kind: 'validate',\n expr: 'item.charge_id == before.charge_id',\n message: 'a pass keeps the charge that paid for it',\n },\n },\n },\n\n // Auth mail (the claim code) and the \"your brief is ready\" message ride\n // this. `mock` needs no account; point it at your own provider to go live.\n notifications: { provider: 'mock', fromEmail: 'hello@studio.example' },\n\n // DECLARED API STATE #1 \u2014 rate-limit policies. These used to be rows you\n // created by hand with POST /v1/rate-limits/policies and re-created in every\n // environment; now `vxil push` converges the live set onto this list by\n // name (creates what is missing, patches what differs, REPORTS what you did\n // not declare). Your server checks them with POST /v1/rate-limits/check.\n 'rate-limits': {\n enabled: true,\n policies: [\n // one visitor, thirty step writes a minute \u2014 enough for a human, not a loop\n { name: 'funnel-step', key_template: '{tenant_id}:{user_id}', limit: 30, window_seconds: 60 },\n // three claim codes per address per ten minutes; pass a HASH of the\n // address as `email_hash`, never the address itself (it becomes a key)\n { name: 'claim-request', key_template: '{tenant_id}:{email_hash}', limit: 3, window_seconds: 600, behavior: 'block' },\n ],\n },\n\n webhooks: {\n enabled: true,\n // FAILURE ALERTS. Every error-level failure event in this backend (a dead\n // delivery, a failed generation, a missed schedule) is digested into a\n // mail to the project's owner accounts every 15 minutes. Recipients are\n // deliberately not configurable; `digestMinutes: 0` makes it immediate.\n alerts: { enabled: true, minLevel: 'error', digestMinutes: 15 },\n // DECLARED API STATE #2 \u2014 an outbound subscription. Converged by\n // target_url on push; a live row you did not declare is left and\n // reported. The three function triggers below are NOT declared here \u2014\n // their subscriptions derive from the functions manifest.\n subscriptions: [\n { target_url: 'https://ops.studio.example/vxil/events', event_prefixes: ['payments.', 'auth.user.'] },\n ],\n },\n\n ai: {\n enabled: true,\n defaultProvider: 'mock', // BYO key later: providers.openaiKeyRef / anthropicKeyRef / geminiKeyRef\n defaults: { maxTokens: 800, temperature: 0.4 },\n // DECLARED API STATE #3 \u2014 the stored prompt template. Converged by\n // CONTENT: a push stores a new version only when the text changed, so\n // pushes are idempotent and versions stay monotonic. vxil stores and\n // renders it; the wording is yours.\n templates: [\n {\n template: 'studio-brief',\n system:\n 'You are a senior brand strategist. Write in plain, confident English. ' +\n 'Never invent facts about the client; when something is unknown, say what you would need.',\n user:\n 'Write a one-page creative brief for a studio client.\\n\\n' +\n 'Goal: {{goal}}\\nAudience: {{audience}}\\nTone: {{tone}}\\nConstraints: {{constraints}}\\n\\n' +\n 'Sections: Objective, Audience insight, Key message, Deliverables, Next steps.',\n },\n ],\n },\n\n // The job lane the brief runs on. `retry` is the ladder for JOB RUNS \u2014 the\n // generation itself, and deliveries to your own endpoints. It is NOT a\n // retry of what a platform-delivered function ANSWERS: the dispatcher ACKs\n // every trigger delivery with a 200 whatever the function returned (guide\n // 08, \"What is retried, and what is not\"), so the three functions below\n // make their failures recoverable through the rows they own \u2014 a 5xx from\n // one of them is observed (`functions.run.failed` \u2192 `webhooks.alerts`),\n // never re-delivered.\n jobs: { enabled: true, retry: { defaultMaxAttempts: 3 } },\n\n // A payments INTEGRATION: the tenant's own Stripe/Paddle/PayPal/RevenueCat\n // account; vxil is never in the flow of funds. `mock` needs no account and\n // runs everything UP TO the paywall \u2014 push, deploy, the intake, the claim\n // and every refusal (402 pass_required, 409 intake_incomplete). It cannot\n // put a PAID charge on the ledger: its checkout URL is a placeholder, its\n // webhooks are signed with the platform's secret, and\n // `vx.payments.simulate` scripts subscriptions, not one-off charges \u2014 so on\n // `mock` grant-pass never wakes and start-brief answers 402 to everyone.\n // The paid path needs your provider's sandbox (README, \"Running the paid\n // path\"), e.g. a Stripe test-mode account:\n // payments: { enabled: true, provider: 'stripe', defaults: { currency: 'usd' },\n // stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' } },\n payments: { enabled: true, provider: 'mock', defaults: { currency: 'usd' } },\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 // One row per visitor intake. Created and stepped by the browser as the\n // (guest, then claimed) end-user; completed and settled by functions.\n funnel_sessions: {\n singular: 'funnel_session',\n // The owner. In end-user mode the platform STAMPS this with the verified\n // user id on create and scopes every read/patch/delete to it \u2014 a body\n // naming someone else is a 400, and a foreign row is an honest 404.\n ownerField: 'user',\n fields: {\n user: { type: 'string', required: true, indexSlot: 's1' },\n status: { type: 'string', indexSlot: 's2', validation: { enum: ['in_progress', 'completed'] } },\n generation_id: { type: 'string', indexSlot: 's3' }, // the job-lane generation that owns the result\n result_status: { type: 'string', indexSlot: 's4', validation: { enum: ['queued', 'completed', 'failed'] } },\n step: { type: 'int', indexSlot: 'n1', validation: { min: 1, max: 4 } }, // 1..4, forward only\n started_at: { type: 'datetime', indexSlot: 't1' },\n completed_at: { type: 'datetime', indexSlot: 't2' },\n answers: { type: 'json' }, // { goal, audience, tone, constraints } \u2014 the template's inputs\n result: { type: 'text' }, // the settled brief\n error_hint: { type: 'string' }, // the provider's own words when the brief failed\n },\n },\n\n // One row per PAID pass, written by the grant function on the provider's\n // charge event. `charge_id` is the dedupe anchor: an at-least-once\n // redelivery of the same charge is a clean 409 the function treats as\n // \"already granted\". It is the DISPLAY RECORD, never the entitlement: the\n // collection is owned, so the visitor's own thin-client key (cms:write)\n // can create a pass or stretch its expires_at \u2014 start-brief decides from\n // the owner-bound payments ledger instead.\n passes: {\n singular: 'pass',\n ownerField: 'user',\n fields: {\n user: { type: 'string', required: true, indexSlot: 's1' },\n charge_id: { type: 'string', required: true, unique: true, indexSlot: 's2' },\n product: { type: 'string', indexSlot: 's3' },\n amount_cents: { type: 'int', indexSlot: 'n1' },\n granted_at: { type: 'datetime', indexSlot: 't1' },\n expires_at: { type: 'datetime', indexSlot: 't2' }, // the brief function's range filter\n },\n },\n },\n },\n\n // \u2500\u2500 The domain logic that ISN'T config: three event-driven functions \u2500\u2500\u2500\u2500\u2500\u2500\n functions: {\n // THE BRIEF. Invoked by the browser AS THE SIGNED-IN VISITOR\n // (vx.asEndUser(token).fn['start-brief']({ session_id })): the end-user\n // principal rides into the function, so its cms reads are owner-scoped and\n // the generation is billed to that user. Requires a PAID pass, read from\n // the owner-bound ledger (`payments:read`) on every call \u2014 never from the\n // visitor-writable `passes` row, which it re-writes as the record when a\n // grant delivery was lost; CLAIMS the row with If-Match before spending\n // anything (a double click is one generation);\n // starts the brief on the ai JOB lane with correlation_id = the session row id.\n 'start-brief': {\n entry: './functions/start-brief.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'ai:write', 'payments:read'],\n egressAllow: [],\n signature: {\n input: { session_id: 'string' },\n // Two 202 answers, so the output is a whole TS type (a union): a new\n // brief's handle, or `already_queued` when one is running \u2014 whose\n // generation_id is null while that claim has not recorded its handle.\n output:\n '{ generation_id: string; run_id: string; correlation_id: string }' +\n \" | { generation_id: string | null; status: 'already_queued' }\",\n },\n },\n\n // THE GRANT. Woken by ONE platform event \u2014 `payments.charge.succeeded`, the\n // full event name as the `source` (the prefix `payments.charge.` would wake\n // it twice per Paddle charge, on `succeeded` and on `completed`) \u2014 a few\n // seconds after your provider's webhook folds. If your pass were a TIER for\n // a number of days rather than a count of briefs, you would not write this\n // function at all: `payments.ledger.productMap['studio-pass'] = { tier: 'pass',\n // durationDays: 30 }` and the platform writes the pass on the charge event\n // (stacking, refund-aware). Re-reads the charge from the ledger (the event name is a\n // routing claim, not an attestation), then writes the pass. A 5xx from it\n // is ACKed and observed, not re-delivered \u2014 start-brief decides from the\n // same ledger, so a lost grant never blocks a payer.\n 'grant-pass': {\n entry: './functions/grant-pass.ts',\n trigger: { kind: 'webhook', source: 'payments.charge.succeeded' },\n scopes: ['payments:read', 'cms:write'],\n egressAllow: [],\n },\n\n // THE SETTLE. Woken by the `job.generation.` prefix \u2014 completed AND failed \u2014\n // and finds its own row through the event's `correlation_id` (no run\u2192record\n // table of your own). On completed: the brief text from the replay buffer\n // + a \"ready\" message. On failed: `error_hint` \u2014 the provider's own words \u2014\n // onto the row, so the visitor sees why, not just that. A settle that\n // cannot complete marks the row `failed` (the retry is one click) \u2014 the\n // delivery is ACKed whatever it answers, so recovery lives in the row.\n 'settle-brief': {\n entry: './functions/settle-brief.ts',\n trigger: { kind: 'webhook', source: 'job.generation.' },\n scopes: ['cms:read', 'cms:write', 'ai:read', 'notifications:send'],\n egressAllow: [],\n },\n },\n});\n",
16695
+ "readme": "# Funnel SaaS \u2014 the studio starter (saas)\n\nThe **web-first funnel** blueprint: a visitor lands, starts as a **guest**, walks a guided intake,\n**claims** the session with an e-mail code, buys a **pass** through your own payments provider,\nand gets an AI-written brief **settled back onto their record** \u2014 all of it declared in one typed\n`vxil.config.ts`, with three small functions for the parts that are code.\n\nIt is the shape most \"studio\" products have \u2014 a lead magnet, a paid deliverable, an account that\nonly exists once someone cares \u2014 and it is also the blueprint that shows **declared API state**:\nrate-limit policies, an outbound subscription and a stored prompt template live in the config and\nconverge on `vxil push`, so a second environment is a push, not a runbook.\n\n```bash\nvxil init --template funnel-saas\nvxil quickstart # or `vxil link <slug>` for an existing backend\nvxil push # collections + hooks + the three functions + the declared API state\nvxil gen # typed SDK: vx.from('funnel_sessions'), vx.fn['start-brief']\n```\n\n**What it provisions:**\n- `funnel_sessions` \u2014 `user` (the **owner field**), `status` (`in_progress \u2192 completed`, hook-guarded),\n `step` (1\u20134, forward only), `answers` (json), then the brief's `generation_id`, `result_status`\n (`queued | completed | failed`), `result` and `error_hint`.\n- `passes` \u2014 `user` (owner), `charge_id` (**unique** \u2014 the dedupe anchor), `product`, `amount_cents`,\n `granted_at`, `expires_at`. The record the page shows \u2014 the payments ledger is the entitlement.\n- Features: `auth` (guest + OTP claim + a refreshable session pair) \xB7 `cms` (**`strictEndUserScope: true`**) \xB7\n `rate-limits` (two **declared policies**) \xB7 `webhooks` (**`alerts` on** + one **declared subscription**) \xB7\n `ai` (one **declared template**) \xB7 `jobs` \xB7 `payments` (a payments **integration** \u2014 your provider; `mock`\n here, which runs everything *up to* the paywall \u2014 [Running the paid path](#running-the-paid-path)) \xB7\n `notifications` (`mock`) \xB7 `functions`.\n- Functions: `start-brief` (http, invoked as the visitor), `grant-pass` (webhook on\n `payments.charge.succeeded`), `settle-brief` (webhook on the `job.generation.` prefix).\n\n> **A simpler pass, if your pass is a tier for a number of days.** Since 2026-09-25 a\n> `ledger.productMap` entry may be `{ tier: 'pass', durationDays: 30 }`: the platform then writes\n> the pass itself on the charge event (stacking behind a live one, ended by a full refund or\n> chargeback), and `GET /v1/payments/entitlements` answers \"is this visitor on a pass, until when\".\n> This blueprint keeps its own `passes` record and `grant-pass` function on purpose \u2014 its pass is a\n> *count of briefs*, decided from the owner-bound charge ledger, not a tier for a duration.\n\n## The funnel, step by step\n\nEvery call below is the browser talking to your backend \u2014 with **two keys**, the way\nvxil.com/docs/guide/09-security-and-multitenancy prescribes: a **sign-in key** (the ordinary class,\ncarrying only `auth:signin`) for the moment *before* there is a session, and a **thin-client key**\n(`key_class: 'public'` \u2014 `cms:read`, `cms:write`, `functions:invoke`) for everything after, which the\nedge refuses unless it rides with a valid end-user token. A public key *cannot* carry `payments:write`\n(a `422` at mint), so the checkout is the one call your server makes \u2014 the last section.\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nconst signin = Vxil.connect({ apiKey: SIGNIN_KEY }); // auth:signin only \u2014 works with no session\nconst thin = Vxil.connect({ apiKey: THIN_CLIENT_KEY }); // public class \u2014 needs a session on every call\n\n// 1. A guest. Real user id, real session, no e-mail yet (the token carries `anon: true`).\nconst guest = await signin.auth.anonymous.signIn();\nconst me = thin.asEndUser(guest.session.token);\n\n// 2. The intake. Owner-scoped: the platform stamps `user` with the guest's id on create,\n// and this session (and only this session) can read and step the row.\nconst { item_id: session_id } = await me.from('funnel_sessions').create({\n status: 'in_progress', step: 1, started_at: new Date().toISOString(),\n answers: { goal: 'Launch a coffee subscription' },\n});\nawait me.from('funnel_sessions').patch(session_id, { step: 2, answers: { goal: '\u2026', audience: '\u2026' } });\n// \u2026steps 3 and 4. A PATCH that moves `step` backwards is a 422 from the `steps_forward_only` hook.\n\n// 3. The claim. A 6-digit code proves the address; the guest is PROMOTED IN PLACE \u2014\n// same user id, so the intake rows are already theirs. (Sign-in class: the sign-in key.)\nawait signin.auth.anonymous.link.request({ token: guest.session.token, email: 'ada@example.com' });\nconst claimed = await signin.auth.anonymous.link.verify({ token: guest.session.token, email: 'ada@example.com', code: '123456' });\n// claimed.user_id === guest.user_id \u2014 unless the address already had an account: then\n// `merged: true`, a fresh session, and `rekeyed` says whether the guest's rows moved already.\n\n// 4. The pass \u2014 only after the claim. Your SERVER refuses a guest, creates the hosted checkout\n// (below) and hands the URL back; the provider's webhook folds the charge \u2192\n// `payments.charge.succeeded` \u2192 grant-pass writes the pass record.\n\n// 5. The brief. Invoked AS THE VISITOR: the function's reads are owner-scoped and the\n// generation is metered to them. Until the visitor's ledger holds a paid charge the call\n// throws a VxilError whose `.code` is 'pass_required' (402); an unfinished intake is\n// 'intake_incomplete' (409).\nconst handle = await me.fn['start-brief']({ session_id });\n// \u2192 { generation_id, run_id, correlation_id } for a new brief, or \u2014 a second click while one\n// is running \u2014 { generation_id, status: 'already_queued' }: one generation, metered once,\n// and `generation_id` is null while that attempt has not recorded its handle yet (the\n// declared signature is that union). Either way, watch the ROW, not the handle:\n// \u2026seconds to minutes later, settle-brief writes `result` (or `error_hint`) onto the row and\n// notifications delivers \"Your brief is ready\". A failed brief can be started again: the\n// same call on a row whose result_status is 'failed' queues a new generation.\n```\n\n## What to learn from this\n\n- **`strictEndUserScope: true` is the fail-safe version of owner scoping.** Both collections declare an\n `ownerField`, so a signed-in visitor reaches exactly their own rows; a collection you add later *without*\n one is `403 server_only` for end-users on read and write instead of tenant-wide-shared. Nothing about a\n server call changes \u2014 a server key still sees everything\n (vxil.com/docs/guide/09-security-and-multitenancy).\n- **A guest is a real end-user.** `vx.auth.anonymous.signIn()` mints a real id; owner-scoped rows written\n before the claim stay attached because the OTP claim (`POST /v1/auth/anonymous/link/request` + `/verify`)\n promotes the guest in place. Passing the guest's token as `anonymous_token` on a magic-link request does\n the same through mail (vxil.com/docs/guide/06-feature-catalog).\n- **Declared API state.** Three things that used to be rows you created by hand \u2014 and re-created in every\n environment \u2014 are config now, and `vxil push` converges the live rows onto them, reporting (never\n deleting) what you did not declare:\n\n | Block | Converged by | The live rows it replaces |\n |---|---|---|\n | `features['rate-limits'].policies[]` | `name` | `POST /v1/rate-limits/policies` |\n | `features.webhooks.subscriptions[]` | `target_url` | `POST /v1/webhooks/subscriptions` |\n | `features.ai.templates[]` | content hash | `POST /v1/ai/templates` (a new version only when the text changed) |\n\n The three function triggers are *not* declared here \u2014 a `webhook`-trigger binding on a function is its\n own declaration, and the platform reconciles that subscription from the functions manifest.\n- **An event-driven grant, done honestly.** `grant-pass` wakes on the full event name\n `payments.charge.succeeded` a few seconds after your provider's webhook folds. The event name is a\n *routing claim*, so the function re-reads the ledger (`GET /v1/payments/charges`) before writing; the\n `passes.charge_id` unique field (the ledger's charge id) turns an at-least-once redelivery into a `409`\n it reads as \"already granted\". And it is honest about **what is not retried**: the dispatcher ACKs a\n platform-delivered function with a `200` *whatever it answered*, so a `5xx` from the API is never\n re-delivered \u2014 it is *observed* (`functions.run.failed`, once per failure streak, in the\n `webhooks.alerts` digest), and recovery is data, not delivery: `start-brief` decides from the same\n owner-bound ledger on every request, so a lost grant never blocks a payer, and it writes the missing\n pass record for that charge (a `409` there just means it is recorded already)\n (vxil.com/docs/guide/08-running-your-code-functions).\n- **An owned row the visitor can write is never an entitlement.** `passes` declares an `ownerField`, so\n the thin-client key (`cms:write`) lets a visitor *create* a pass of their own or PATCH its\n `expires_at` to 2099 \u2014 the platform stamps them as the owner, which proves who wrote the row, not that\n anyone paid, and a write hook's `caller` is always `null`, so no hook can tell that write from\n `grant-pass`'s (vxil.com/docs/guide/07-validation-and-hooks). So `start-brief` never reads `passes` to\n decide: on every call it reads `GET /v1/payments/charges?status=succeeded` as the visitor (bound to\n the verified principal; no key a browser holds can write the ledger) and needs a succeeded, unrefunded\n charge from the last 365 days. The pass row is what the page *shows*; the ledger is what it *checks*.\n- **`correlation_id` closes the loop.** `start-brief` sends the session row id as `correlation_id` on\n `POST /v1/ai/generate` (`mode: 'job'`); the platform echoes it on `job.generation.completed` /\n `job.generation.failed` next to `generation_id`, so `settle-brief` finds its own row from the event alone.\n On `failed` it writes **`error_hint`** \u2014 the provider's own status and message \u2014 onto the row, so the\n visitor sees *why* (and `start-brief` accepts that row again, so a retry is one more click); on\n `completed` the text comes from the generation's replay buffer\n (`GET /v1/ai/generations/{generation_id}/stream?since=0`). Every settle write carries\n `if: { generation_id }`, so a late delivery for an earlier attempt can never overwrite a newer one; and a\n settle that cannot complete marks the row `failed` with `settle failed: <reason>` instead of answering a\n status nothing will act on \u2014 the row, not the delivery, is where recovery lives.\n- **The claim comes before the spend.** `start-brief` reads the row's `version`, PATCHes\n `result_status: 'queued'` with `If-Match: <version>`, and only then calls generate. A double click that\n lands inside the generate round-trip loses the compare-and-set with `409 version_conflict` and gets a\n `202 already_queued` \u2014 one generation, one credit reserve. A claim whose generate never recorded, or a\n queued handle older than the job lane's ceiling, is treated as failed on the next click, so a row can\n never stick in `queued`. Every write after the claim (recording the handle, marking a failure) carries\n `If-Match: <the claim's version>`, so an attempt the next click already swept can never overwrite the\n newer claim \u2014 its `409` means \"the row moved on\", and it steps aside without a write.\n- **Function errors are coded.** Every refusal a function answers is `{ error: { code, message } }` \u2014\n `POST /v1/fn/:name` hands that body back verbatim, and the SDK raises it as a `VxilError` whose `.code`\n is the function's own (`pass_required`, `intake_incomplete`, `already_settled`), so a page branches on a\n code, never on a message. The platform-delivered functions answer `200 { skipped }` for a decision, and a\n `500` with a code (`unsettled`, `grant_unsettled`) only when not even the row could record the failure \u2014\n not to be retried (nothing re-delivers it) but to be *seen* through `functions.run.failed`.\n- **Failure alerts are on.** `webhooks.alerts` mails the project's owner accounts every 15 minutes with the\n error-level failure events \u2014 a dead delivery, a failed generation, a missed schedule. Recipients are\n deliberately not configurable; `digestMinutes: 0` makes it immediate.\n- **The payments feature is an integration.** Your Stripe, Paddle, PayPal or RevenueCat account, the\n provider's hosted checkout, the provider's webhook; vxil folds the events and keeps the ledger. The\n scaffold's `mock` provider stops at the paywall, so the paid path runs on your provider's sandbox \u2014 the\n next section. To rehearse the grant chain without a card, Paddle's simulator signs with your real endpoint\n secret and your `payments.` functions run for real (vxil.com/docs/guide/06-feature-catalog).\n\n## Server side: keep the visitor's token fresh\n\nA session token lives `session.ttlMinutes` (60 here); the cookie your app sets lives days. Every sign-in\nreturns a pair \u2014 the token and a `refresh_token` good for `session.refreshTtlDays` (30) \u2014 and\n`POST /v1/auth/sessions/refresh` rotates it. So a server-rendered app needs one root middleware, not a\nredesign (vxil.com/docs/guide/09-security-and-multitenancy):\n\n```ts\n// once per request, before any loader runs\nconst { token, refresh_token, expires_at } = readSessionCookie(request);\nlet session = { token, refresh_token, expires_at };\nif (Date.parse(expires_at) - Date.now() < 5 * 60_000) { // within 5 min of expiry (or past it)\n try {\n session = (await vx.auth.sessions.refresh(refresh_token)).session; // rotated pair\n setSessionCookie(response, session); // store BOTH halves again\n } catch {\n clearSessionCookie(response); // refresh revoked or expired: sign in again\n }\n}\nconst asUser = vx.asEndUser(session.token); // every per-visitor read below is scoped by the platform\n```\n\nStore the refresh token only in the `HttpOnly` cookie; refresh *early* rather than on `401`, so parallel\nloaders do not race to rotate the same token; keep the server key for the tenant-wide rows only.\n\nThe same server owns the **checkout** \u2014 `POST /v1/payments/checkout-sessions` needs `payments:write`, which\na public key cannot carry, and it is server-only (a `403 server_only` in end-user mode). Three rules shape it:\n\n- **The buyer is the session, never a form field.** Ask the platform whose token it is:\n `vx.auth.sessions.verify` answers with the account *as it is now* (`auth:read` on the server key).\n- **A guest cannot buy \u2014 claim first.** A charge stays with the user id that paid it. When a guest claims an\n address that already has an account, the merge re-keys the guest's subscriptions, customer links and\n credit balances onto that account (vxil.com/docs/guide/06-feature-catalog, \"What a merge moves\") \u2014 not its\n one-off charges \u2014 and `start-brief` decides from the charges ledger. A guest who paid and *then* merged\n would have paid for a brief the merged account can never start. So refuse the checkout until the claim:\n `verified` is `false` while the visitor has no proven address \u2014 a guest has none, and the claim *is* the\n proof (every other way into this blueprint, a magic link or a code, proves one too). Not the token's\n `anon` claim: that is a snapshot from when the token was minted, not the account as it is now.\n- **One Idempotency-Key per purchase *attempt*, never per visitor.** A key the payments feature has seen\n replays its first session verbatim, and the key never expires \u2014 so `pass:${user_id}` would hand a visitor\n whose checkout expired, who was refunded, or who comes back after the 365-day pass the same dead URL\n forever. Key the attempt: the buy page renders a fresh attempt id into its form, so a double click posts\n the same id (one session), while a reload, a return from the checkout or next year's renewal renders a\n new one. The id is not a credential \u2014 the `user_id` next to it is the verified one, so an invented id\n only ever opens another checkout for the visitor's own account.\n\n```ts\n// the visitor is signed in (the middleware above keeps `session` fresh); the form posts `attempt`\nconst who = await vx.auth.sessions.verify(session.token); // { user_id, verified, \u2026 } \u2014 the account NOW\nif (!who.verified) { // still a guest: charges never follow a merge\n return Response.json({ error: { code: 'claim_required', message: 'claim your e-mail before you buy' } }, { status: 409 });\n}\nconst attempt = String(form.get('attempt') ?? ''); // crypto.randomUUID(), rendered with the buy page\nif (!/^[\\w-]{16,64}$/.test(attempt)) {\n return Response.json({ error: { code: 'bad_attempt', message: 'reload the page and try again' } }, { status: 400 });\n}\nconst { url } = await vx.payments.createCheckoutSession(\n { user_id: who.user_id, mode: 'payment',\n line_items: [{ price_ref: 'studio-pass', amount_cents: 4900 }], // a real provider takes ITS price id \u2014 below\n success_url: 'https://studio.example/thanks', cancel_url: 'https://studio.example/pass' },\n { idempotencyKey: `pass:${who.user_id}:${attempt}` },\n);\n// redirect the visitor to `url` \u2014 a hosted checkout on YOUR provider; vxil is never in the flow of funds.\n```\n\nThe same server is where the declared rate-limit policies are checked. `POST /v1/rate-limits/check` takes a\n`policy_id`, never a name \u2014 list the policies once after the first push and keep the two ids:\n\n```ts\nconst policies = await vx.rateLimits.listPolicies(); // RateLimitPolicy[] \u2014 \u2264100, no cursor\nconst stepPolicy = policies.find((p) => p.name === 'funnel-step')!.policy_id;\n// before forwarding a step write\nawait vx.rateLimits.check({ policy_id: stepPolicy, key_values: { user_id }, cost: 1 }); // 429 + Retry-After when blocked\n// before forwarding a claim request \u2014 a HASH of the address, never the address (it becomes a key)\nawait vx.rateLimits.check({ policy_id: claimPolicy, key_values: { email_hash: sha256(email) } });\n```\n\n## Running the paid path\n\nThe scaffold ships `payments.provider: 'mock'`, so `vxil push` works with no provider account, and\neverything but the payment runs on it: the intake, the claim, the deploy, and every refusal\n(`402 pass_required`, `409 intake_incomplete`, a guest's `claim_required`). What `mock` cannot do is put a\n**paid** charge on the ledger: its checkout URL is a placeholder host nothing serves, its webhooks are\nsigned with the platform's own secret rather than one you hold, and `vx.payments.simulate` scripts\n*subscription* lifecycles, not a one-off charge. So on `mock`, `grant-pass` never wakes and `start-brief`\nanswers `402 pass_required` to every visitor \u2014 the paywall doing its job. To run the brief end to end,\npoint the block at your provider's sandbox, in a project of its own (the signing-secret ref is one per\nprovider \u2014 vxil.com/docs/guide/06-feature-catalog):\n\n```ts\n// vxil.config.ts \u2014 a Stripe TEST-mode account (a Paddle sandbox is `provider: 'paddle'` + `paddle: { \u2026 }`)\npayments: {\n enabled: true, provider: 'stripe', defaults: { currency: 'usd' },\n stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' },\n},\n```\n\n```bash\nvxil secrets set payments/stripe_secret # the test-mode secret key, at the hidden prompt\nvxil secrets set payments/stripe_webhook # the signing secret of the endpoint below\nvxil push\n```\n\nRegister `https://api.vxil.com/v1/internal/payments/webhook/stripe/<tenantId>` as the endpoint in the\nprovider's dashboard. On a real provider `line_items[].price_ref` is the provider's own price id (Stripe\n`price_\u2026`), not `studio-pass`. Pay with the provider's test card: the charge folds \u2192\n`payments.charge.succeeded` \u2192 `grant-pass` records the pass, and the next `start-brief` queues the brief.\n\n**Go deeper:** vxil.com/docs/guide/09-security-and-multitenancy (end-user mode \xB7 `strictEndUserScope` \xB7 the\nrefresh middleware) \xB7 vxil.com/docs/guide/06-feature-catalog (auth guests and claims \xB7 rate-limits \xB7\nwebhooks alerts and declared subscriptions \xB7 ai `mode: 'job'` \xB7 payments) \xB7\nvxil.com/docs/guide/08-running-your-code-functions (the `webhook` trigger \xB7 at-least-once) \xB7\nvxil.com/docs/guide/04-data-with-cms (owner scoping \xB7 the query DSL).\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
15864
16696
  "functions": {
15865
16697
  "grant-pass.ts": "// grant-pass.ts \u2014 GRANT A PASS ON A SUCCESSFUL CHARGE (a vxil function, \xA77.3).\n//\n// Trigger: webhook, source 'payments.charge.succeeded' \u2014 the FULL event name, so\n// this function wakes for exactly one event: the moment your provider's webhook\n// (Stripe, Paddle, PayPal, RevenueCat, or the mock) folds a succeeded charge.\n// The platform wires the subscription for you on deploy; the first attempt is\n// delivered directly, a few seconds after the event.\n//\n// The envelope's `payload` is the event, flattened:\n// { event, audit_id, occurred_at, actor, surface, data }\n// and `data` is the payments.charge.succeeded payload:\n// { end_user_id, provider, provider_charge_id, amount_cents, currency, product_id, environment }\n//\n// THREE RULES this function lives by:\n// \u2022 The event NAME is a routing claim, not an attestation \u2014 anyone who can\n// enqueue a job in your own project can produce a delivery with a chosen\n// name. So the ledger is re-read (GET /v1/payments/charges) before a pass\n// is written; a charge the ledger does not know is skipped.\n// \u2022 Delivery is at-least-once. `passes.charge_id` is UNIQUE (the LEDGER's\n// charge id \u2014 the same key start-brief's record write uses), so a redelivery\n// is a clean 409 unique_violation treated as \"already granted\".\n// \u2022 Nothing this function ANSWERS is re-delivered. The dispatcher ACKs every\n// platform-delivered trigger with a 200 whatever the handler returned\n// (guide 08, \"What is retried, and what is not\") \u2014 so a 5xx here is not a\n// retry request, it is the OBSERVATION channel: `functions.run.failed`\n// (once per failure streak) \u2192 the `webhooks.alerts` digest. Recovery is\n// data, not delivery: the pass row is a DISPLAY RECORD (an owned row the\n// visitor's own key could write, so it is never the entitlement) \u2014\n// start-brief decides from the same owner-bound ledger on every request,\n// so a grant lost here never blocks a payer, and it re-writes the missing\n// record. A decision (skip) answers 200 and is a decision.\n\nimport type { WebhookFunctionEnvelope } from '@vxil/sdk';\n\n/** the `payments.charge.succeeded` event payload (the fields read here) */\ninterface ChargeSucceeded {\n end_user_id?: string | null;\n provider_charge_id?: string | null;\n amount_cents?: number | null;\n currency?: string | null;\n product_id?: string | null;\n}\ntype Env = WebhookFunctionEnvelope<ChargeSucceeded>;\ninterface Charge { charge_id: string; amount_cents: number; currency: string; status: string }\ninterface ChargeList { data?: { charges?: Charge[] } }\ninterface Created { data?: { item_id?: string } }\ninterface ErrBody { error?: { code?: string } }\n\nconst PASS_DAYS = 365; // mirrored in start-brief.ts (the ledger gate)\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 pay = env.scoped_jwts?.payments;\n const cms = env.scoped_jwts?.cms;\n if (!pay || !cms) return fail(403, 'missing_scopes', 'the function needs payments:read and cms:write');\n // The binding's `source` already filters deliveries; this stays as belt-and-braces.\n if (env.payload?.event !== 'payments.charge.succeeded') {\n return Response.json({ skipped: true, event: env.payload?.event });\n }\n const raw = env.payload.data;\n // Past 64 KiB the whole `data` value is replaced by { truncated: true } \u2014\n // never a half object. A charge payload is tiny, but the check is free.\n if (raw && 'truncated' in raw) return Response.json({ skipped: true, reason: 'payload truncated' });\n const data: ChargeSucceeded = raw ?? {};\n const userId = data.end_user_id ?? null;\n const providerChargeId = data.provider_charge_id ?? null;\n if (!userId || !providerChargeId || data.amount_cents == null) {\n return Response.json({ skipped: true, reason: 'charge carries no user, id or amount' });\n }\n\n // 1. Re-read the LEDGER (the authoritative record) \u2014 a succeeded charge of\n // this amount must exist for this user. This is what turns the event\n // name from a claim into a fact. (One product, one pass per purchase:\n // the match is by amount; the list is newest-first.)\n const led = await fetch(\n `${base}/v1/payments/charges?user_id=${encodeURIComponent(userId)}&status=succeeded&limit=50`,\n { headers: H(pay) },\n ).catch(() => new Response(null, { status: 599 }));\n if (led.status >= 500) return unsettled(`ledger read ${led.status}`);\n if (!led.ok) return Response.json({ skipped: true, reason: `ledger read ${led.status}` });\n const charges = ((await led.json()) as ChargeList).data?.charges ?? [];\n const match = charges.find((c) => c.status === 'succeeded' && c.amount_cents === data.amount_cents\n && (!data.currency || c.currency === data.currency));\n if (!match) return Response.json({ skipped: true, reason: 'no succeeded charge of that amount on the ledger' });\n\n // 2. Write the pass RECORD. Server mode, so `user` is set explicitly (in\n // end-user mode the platform would stamp it). The ledger's `charge_id` is\n // the unique anchor \u2014 start-brief's record write keys on the same id.\n const now = Date.now();\n const created = await fetch(`${base}/v1/cms/items/passes`, {\n method: 'POST',\n headers: H(cms),\n body: JSON.stringify({\n data: {\n user: userId,\n charge_id: match.charge_id,\n product: data.product_id ?? 'studio-pass',\n amount_cents: data.amount_cents,\n granted_at: new Date(now).toISOString(),\n expires_at: new Date(now + PASS_DAYS * 86_400_000).toISOString(),\n },\n }),\n }).catch(() => new Response(null, { status: 599 }));\n if (created.status === 409) {\n const code = ((await created.json().catch(() => ({}))) as ErrBody).error?.code;\n // the redelivery case \u2014 the pass is already there\n return Response.json({ granted: false, reason: code ?? 'already granted', charge_id: match.charge_id });\n }\n if (created.status >= 500) return unsettled(`pass write ${created.status}`);\n if (!created.ok) return Response.json({ skipped: true, reason: `pass write ${created.status}` });\n const itemId = ((await created.json()) as Created).data?.item_id;\n return Response.json({\n granted: true, pass: itemId, user: userId, charge_id: match.charge_id, provider_charge_id: providerChargeId,\n });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst fail = (status: number, code: string, message: string) =>\n Response.json({ error: { code, message } }, { status });\n/** The charge succeeded and no pass was written. Answer 500 so the run is SEEN\n * (`functions.run.failed` \u2192 the alert digest); it is ACKed, never re-delivered\n * \u2014 start-brief decides from the ledger (the payer is never blocked) and\n * re-writes the missing record on the next request. */\nconst unsettled = (reason: string) =>\n fail(500, 'grant_unsettled', `${reason} \u2014 no pass written; surfaced via functions.run.failed (not re-delivered), repaired by start-brief from the ledger`);\n",
15866
16698
  "settle-brief.ts": "// settle-brief.ts \u2014 SETTLE THE BRIEF ONTO ITS ROW (a vxil function, \xA77.3).\n//\n// Trigger: webhook, source 'job.generation.' \u2014 the PREFIX, so this function\n// wakes for both `job.generation.completed` and `job.generation.failed`. The\n// envelope's `payload.data` is the event payload:\n// { run_id, generation_id, correlation_id, status, error_class, error_hint, state, level }\n//\n// `correlation_id` is the funnel_sessions row id that start-brief sent with\n// the generate call \u2014 the platform echoes it on the event next to\n// generation_id, so this function needs no run\u2192record table of its own.\n//\n// On COMPLETED: the settled text lives in the generation's replay buffer\n// (GET /v1/ai/generations/{id}/stream?since=0 \u2014 every recorded frame, plus\n// `done`); the token frames are joined and PATCHed onto the row, then the\n// visitor gets a \"ready\" message. On FAILED: `error_hint` carries the\n// provider's own status and message (`Upstream said: \u2026`) and `error_class`\n// the class \u2014 the row gets the hint, so the visitor (and your support inbox)\n// sees WHY, not just that it failed. On completed both are null by contract.\n//\n// THE DELIVERY CONTRACT (guide 08, \"What is retried, and what is not\"): on\n// every platform-delivered trigger the dispatcher ACKs the delivery with a 200\n// WHATEVER this handler returns. A 5xx from here is never re-delivered \u2014 it is\n// OBSERVED (`functions.run.failed`, once per failure streak, digested by\n// `webhooks.alerts`). Only a crash of the dispatch itself is retried. So this\n// function never answers a 5xx hoping for a redelivery; it makes every failure\n// RECOVERABLE THROUGH THE ROW instead:\n// \u2022 a settle that cannot complete marks the row `result_status: 'failed'`\n// with `error_hint: 'settle failed: <reason>'` \u2014 start-brief accepts that\n// row again, so the visitor's retry is one click;\n// \u2022 only when not even that mark could be written does it answer 500 \u2014 not\n// for a retry (there is none) but so the failure is SEEN in the alert\n// digest instead of vanishing behind a 200.\n// The one transient it waits out ITSELF is the replay buffer: `done: false`\n// means the terminal frame is a moment behind the event, so the read is\n// repeated a few times before giving up (the guide's \"retry internally\").\n//\n// Every write carries `if: { generation_id }` \u2014 the row is touched only while\n// it is still waiting on THIS generation, so a late delivery for an earlier\n// attempt can never overwrite a newer one (409 precondition_failed = stale).\n// At-least-once: the same PATCH twice is the same row, and the \"ready\" send\n// carries the delivery's idempotency key, so a redelivery never double-mails.\n\nimport type { JobGenerationSettledEventPayload, WebhookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = WebhookFunctionEnvelope<JobGenerationSettledEventPayload>;\ninterface SessionData { user?: string; generation_id?: string | null; result_status?: string }\ninterface Item { data?: { data?: SessionData } }\ninterface Replay { data?: { frames?: Array<{ type?: string; delta?: string; done?: boolean }>; done?: boolean } }\n\nconst REPLAY_TRIES = 4; // reads of the replay buffer before giving up \u2026\nconst REPLAY_WAIT_MS = 500; // \u2026 and the pause between them (wall time, not CPU)\nconst ROW_TRIES = 3; // re-reads of a row still inside start-brief's claim\u2192generate\u2192record window\nconst ROW_WAIT_MS = 700;\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 notif = env.scoped_jwts?.notifications;\n if (!cms || !ai) return fail(403, 'missing_scopes', 'the function needs cms:read, cms:write and ai:read');\n\n const event = env.payload?.event ?? '';\n if (event !== 'job.generation.completed' && event !== 'job.generation.failed') {\n return Response.json({ skipped: true, event }); // e.g. job.generation.processing\n }\n // a truncated payload ({ truncated: true }) carries nothing to settle from\n const raw = env.payload?.data;\n const data: Partial<JobGenerationSettledEventPayload> = raw && !('truncated' in raw) ? raw : {};\n const sessionId = data.correlation_id ?? null;\n const generationId = data.generation_id ?? null;\n if (!sessionId || !generationId) {\n return Response.json({ skipped: true, reason: 'not a funnel generation (no correlation_id)' });\n }\n const row = { base, cms, sessionId, generationId };\n\n // Find our row through the correlation id (server mode: any row). It must\n // be waiting on THIS generation: another id is a stale delivery (an earlier\n // attempt settling late) \u2014 unless the row is `queued` with no handle yet,\n // which is start-brief's claim\u2192generate\u2192record window: the handle is one\n // PATCH away, so the read is repeated before the delivery is called stale.\n let session: SessionData | null = null;\n let readStatus = 0;\n for (let i = 0; i < ROW_TRIES; i++) {\n if (i > 0) await sleep(ROW_WAIT_MS);\n const res = await fetch(`${base}/v1/cms/items/funnel_sessions/${encodeURIComponent(sessionId)}`, { headers: H(cms) });\n readStatus = res.status;\n if (res.status === 404) return Response.json({ skipped: true, reason: 'no such session' });\n if (!res.ok) continue; // a 5xx read: try again, then fall through to the blind mark below\n const s = ((await res.json()) as Item).data?.data ?? {};\n if (s.generation_id === generationId) { session = s; break; }\n if (s.result_status === 'queued' && !s.generation_id) continue; // claimed, handle not recorded yet\n return Response.json({ skipped: true, reason: 'stale generation', expected: s.generation_id ?? null });\n }\n if (!session) {\n if (readStatus >= 500) {\n // The row could not be read. Mark it failed anyway \u2014 the `if`\n // precondition lands the mark only if the row is still waiting on this\n // generation \u2014 and let the visitor retry.\n return await settleFailed(row, `session read ${readStatus}`);\n }\n return Response.json({ skipped: true, reason: 'stale generation (no handle recorded for it)' });\n }\n\n if (event === 'job.generation.failed') {\n // The provider's own words, bounded (the event caps the hint at 200 chars).\n const hint = data.error_hint ?? data.error_class ?? 'generation failed';\n const patch = await patchSession(row, { result_status: 'failed', error_hint: hint });\n if (patch.status === 409) return Response.json({ skipped: true, reason: 'stale generation' });\n if (!patch.ok) return unsettled(`session patch ${patch.status}`);\n return Response.json({ settled: 'failed', session: sessionId, error_class: data.error_class ?? null, error_hint: hint });\n }\n\n // COMPLETED \u2014 the text is in the replay buffer. `done: false` means the\n // buffer has not received its terminal frame yet \u2014 a moment behind the\n // event \u2014 so the read is repeated a few times before the brief is given\n // up (never settled empty; never a 5xx-and-hope: nothing re-delivers).\n let replay: NonNullable<Replay['data']> | null = null;\n let replayStatus = 0;\n for (let i = 0; i < REPLAY_TRIES; i++) {\n if (i > 0) await sleep(REPLAY_WAIT_MS);\n const rep = await fetch(`${base}/v1/ai/generations/${encodeURIComponent(generationId)}/stream?since=0`, { headers: H(ai) });\n replayStatus = rep.status;\n if (rep.status >= 400 && rep.status < 500) break; // not transient \u2014 a 404 generation will not appear\n if (!rep.ok) continue;\n const d = ((await rep.json()) as Replay).data ?? {};\n if (d.done) { replay = d; break; }\n }\n if (!replay) {\n return await settleFailed(row, replayStatus >= 400 ? `replay read ${replayStatus}` : 'replay buffer never reported done');\n }\n const text = (replay.frames ?? []).filter((f) => f.type === 'token').map((f) => f.delta ?? '').join('');\n\n const patch = await patchSession(row, { result_status: 'completed', result: text, error_hint: null });\n if (patch.status === 409) return Response.json({ skipped: true, reason: 'stale generation' });\n if (!patch.ok) return await settleFailed(row, `session patch ${patch.status}`);\n\n // Tell the visitor. Best-effort AFTER the result is on the row: a mail that\n // fails must not un-settle a brief that is ready. The delivery's\n // idempotency key rides the send, so a redelivered event never sends twice\n // (notifications honors Idempotency-Key).\n let delivery: number | null = null;\n if (notif && session.user) {\n const send = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { ...H(notif), ...(env.idempotency_key ? { 'idempotency-key': env.idempotency_key } : {}) },\n body: JSON.stringify({\n user_id: session.user,\n template: 'transactional',\n data: { subject: 'Your brief is ready', paragraph: 'Open the studio to read your creative brief.' },\n }),\n }).catch(() => null);\n delivery = send?.status ?? null;\n }\n return Response.json({ settled: 'completed', session: sessionId, chars: text.length, delivery });\n },\n};\n\n// \u2500\u2500 tiny helpers \u2500\u2500\ninterface Row { base: string; cms: string; sessionId: string; generationId: string }\nconst H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\nconst sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));\nconst fail = (status: number, code: string, message: string) =>\n Response.json({ error: { code, message } }, { status });\n/** Not even the row could record the failure: answer 500 so the run is SEEN\n * (`functions.run.failed` \u2192 the alert digest). ACKed, never re-delivered. */\nconst unsettled = (reason: string) =>\n fail(500, 'unsettled', `${reason} \u2014 the row could not be marked failed; surfaced via functions.run.failed, not re-delivered`);\n/** Every write is guarded by `if: { generation_id }`: the row is touched only\n * while it still waits on THIS generation (409 precondition_failed = stale). */\nfunction patchSession(row: Row, data: Record<string, unknown>): Promise<Response> {\n return fetch(`${row.base}/v1/cms/items/funnel_sessions/${encodeURIComponent(row.sessionId)}`, {\n method: 'PATCH', headers: H(row.cms), body: JSON.stringify({ data, if: { generation_id: row.generationId } }),\n }).catch(() => new Response(null, { status: 599 }));\n}\n/** The recoverable outcome: mark the row failed with the reason, so start-brief\n * accepts it again and the visitor's retry is one click. */\nasync function settleFailed(row: Row, reason: string): Promise<Response> {\n const patch = await patchSession(row, { result_status: 'failed', error_hint: `settle failed: ${reason}`.slice(0, 200) });\n if (patch.ok) return Response.json({ settled: 'failed', session: row.sessionId, reason });\n if (patch.status === 409) return Response.json({ skipped: true, reason: 'stale generation' });\n return unsettled(`${reason}; mark failed \u2192 ${patch.status}`);\n}\n",
@@ -16111,10 +16943,10 @@ const json = (o: unknown, status: number) => Response.json(o, { status });
16111
16943
  "slack_webhook_url",
16112
16944
  "vxil_read_key"
16113
16945
  ],
16114
- "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 * * * *' },\n scopes: ['cms:read', 'cms:write'],\n // Two BYO secrets, resolved per invocation and injected as env.secrets.<name>:\n // slack_webhook_url \u2014 the Slack incoming-webhook (or Discord webhook) URL\n // vxil_read_key \u2014 an API key of YOUR tenant holding ONLY `features:read`;\n // the audit stream is not reachable through the\n // function's scoped callback, so the drain reads it with\n // this least-privilege key instead\n secrets: ['secret:slack_webhook_url', 'secret:vxil_read_key'],\n // Deny-by-default egress: only the chat host (add 'discord.com' for Discord).\n egressAllow: ['hooks.slack.com'],\n },\n },\n\n secrets: {\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord webhook) URL' },\n vxil_read_key: { feature: 'functions', description: 'a vxil API key with only features:read \u2014 reads the audit stream' },\n },\n});\n",
16115
- "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\nvxil init --template alerts-to-slack\nvxil quickstart # or `vxil link <slug>` for an existing backend\nprintf '%s' \"$SLACK_URL\" | vxil secrets set functions/slack_webhook_url\nprintf '%s' \"$READ_KEY\" | vxil secrets set functions/vxil_read_key\nvxil push # collection + the `alerts` cron function\nvxil functions invoke alerts --data '{\"test\":true}' # posts a test message\n```\n\n**The two secrets** (references in the config, values only ever in the encrypted secret store):\n\n| Secret | What it is | Where it comes from |\n|---|---|---|\n| `slack_webhook_url` | a Slack *incoming webhook* URL (or a Discord webhook URL) | Slack \u2192 Apps \u2192 Incoming Webhooks; Discord \u2192 channel \u2192 Integrations \u2192 Webhooks |\n| `vxil_read_key` | an API key **of this tenant** holding **only `features:read`** | dashboard \u2192 API keys \u2192 create, tick `features:read` and nothing else |\n\nWhy a key at all? The audit stream (`GET /v1/audit` and its NDJSON export) is a control-plane read that is\n**not** on a function's scoped callback (the callback covers the feature APIs \u2014 cms, payments, jobs\u2026),\nso the drain reads it with the narrowest key that can: read-only, no write scope, revocable in one click.\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.\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 a `features:read`-only key it holds as a secret, and can reach exactly one external\n host. Rotate either secret without a redeploy \u2014 refs resolve per invocation.\n\n**Latency:** a failure is posted within one cron interval (5 minutes) of the audit row landing.\n**Pairs with:** `templates/payments-heartbeat/` (the silence detector that emits its own crossings) and\n`templates/push-notifications/`.\n",
16946
+ "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 * * * *' },\n scopes: ['cms:read', 'cms:write'],\n // Two BYO secrets, resolved per invocation and injected as env.secrets.<name>:\n // slack_webhook_url \u2014 the Slack incoming-webhook URL (or a Discord webhook,\n // or a Google Chat space webhook)\n // vxil_read_key \u2014 an API key of YOUR tenant holding ONLY `features:read`;\n // the audit stream is not reachable through the\n // function's scoped callback, so the drain reads it with\n // this least-privilege key instead\n secrets: ['secret:slack_webhook_url', 'secret:vxil_read_key'],\n // Deny-by-default egress: only the chat host (add 'discord.com' for Discord,\n // 'chat.googleapis.com' for a Google Chat space webhook).\n egressAllow: ['hooks.slack.com'],\n },\n },\n\n secrets: {\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord / Google Chat space webhook) URL' },\n vxil_read_key: { feature: 'functions', description: 'a vxil API key with only features:read \u2014 reads the audit stream' },\n },\n});\n",
16947
+ "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\nvxil init --template alerts-to-slack\nvxil quickstart # or `vxil link <slug>` for an existing backend\nprintf '%s' \"$SLACK_URL\" | vxil secrets set functions/slack_webhook_url\nprintf '%s' \"$READ_KEY\" | vxil secrets set functions/vxil_read_key\nvxil push # collection + the `alerts` cron function\nvxil functions invoke alerts --data '{\"test\":true}' # posts a test message\n```\n\n**The two secrets** (references in the config, values only ever in the encrypted secret store):\n\n| Secret | What it is | Where it comes from |\n|---|---|---|\n| `slack_webhook_url` | a Slack *incoming webhook* URL (or a Discord webhook URL, or a Google Chat *space webhook* URL) | Slack \u2192 Apps \u2192 Incoming Webhooks; Discord \u2192 channel \u2192 Integrations \u2192 Webhooks; Google Chat \u2192 space \u2192 Apps & integrations \u2192 Webhooks |\n| `vxil_read_key` | an API key **of this tenant** holding **only `features:read`** | dashboard \u2192 API keys \u2192 create, tick `features:read` and nothing else |\n\nWhy a key at all? The audit stream (`GET /v1/audit` and its NDJSON export) is a control-plane read that is\n**not** on a function's scoped callback (the callback covers the feature APIs \u2014 cms, payments, jobs\u2026),\nso the drain reads it with the narrowest key that can: read-only, no write scope, revocable in one click.\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 a `features:read`-only key it holds as a secret, and can reach exactly one external\n host. Rotate either secret without a redeploy \u2014 refs resolve per invocation.\n\n**Latency:** a failure is posted within one cron interval (5 minutes) of the audit row landing.\n**Pairs with:** `templates/payments-heartbeat/` (the silence detector that emits its own crossings) and\n`templates/push-notifications/`.\n",
16116
16948
  "functions": {
16117
- "alerts.ts": "// alerts.ts \u2014 FAILURE EVENTS \u2192 ONE CHAT MESSAGE PER STATE CROSSING (a vxil function).\n//\n// Trigger: cron `*/5 * * * *`. Each run:\n// 1. reads the watermark (the `__cursor__` row in `alert_state`; first run = \"now\",\n// so history never produces an alert storm),\n// 2. drains the tenant audit stream past it \u2014 `GET /v1/audit/export?after_id=\u2026`\n// (ascending, NDJSON) with the `vxil_read_key` secret (a key holding ONLY\n// `features:read`; the audit stream is not on the scoped callback),\n// 3. matches each row against RULES (the allow-list below) and turns it into a\n// (key, state, level) \u2014 e.g. `payments:ingest:stripe` \u2192 `broken`,\n// 4. compares with the stored state for that key (cms, one row per key, `unique`\n// key \u21D2 a racing run gets a 409 and stands down) and posts ONLY on a crossing:\n// ok\u2192red = alert, red\u2192ok = \"resolved\", red\u2192red = silence (or a reminder after\n// REPEAT_AFTER_HOURS),\n// 5. advances the watermark (If-Match on the cursor row's version \u2014 a concurrent\n// run that already advanced it wins; jobs may deliver a cron tick twice).\n//\n// Manual check: `vxil functions invoke alerts --data '{\"test\":true}'` posts a test\n// message and touches nothing else.\n//\n// Tune the constants; own the RULES table \u2014 it is data, not a routing engine.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst REPEAT_AFTER_HOURS = 24; // remind about a still-red condition this often; 0 = never\nconst MAX_PAGES_PER_RUN = 5; // \xD7 500 audit rows \u2014 bounds one cron tick\nconst MAX_POSTS_PER_RUN = 20; // a burst of distinct failures collapses into one summary line\nconst DETAIL_MAX = 240;\n\ntype Level = 'info' | 'warn' | 'error';\ntype State = 'ok' | 'stale' | 'broken' | 'could_not_check';\ntype Payload = Record<string, unknown>;\n\ninterface Rule {\n /** A literal level, or 'payload' to take `payload.level` (the payments failure\n * events carry one; the jobs/functions ones do not). */\n level: Level | 'payload';\n key: (p: Payload) => string; // one state row per key \u2014 this is what suppresses repeats\n title: (p: Payload) => string;\n /** A literal state, or omitted to take `payload.state` (falling back to 'broken'). */\n state?: State;\n}\n\nconst str = (v: unknown, fallback = 'unknown') => (typeof v === 'string' && v ? v : fallback);\n\n/** THE ALLOW-LIST. Every name below is an audit event a vxil feature writes;\n * anything not listed is ignored by the drain. The KEY is what collapses\n * repeats: ten dead letters of the same job are one key, one row, one alert.\n *\n * Every name below is emitted today (2026-09-10). A rule for an event nobody\n * emits would simply never match \u2014 so if you add one, verify the emitter first.\n */\nconst RULES: Record<string, Rule> = {\n // \u2500\u2500 payments \u2014 the money path. A silent failure here is lost revenue. \u2500\u2500\u2500\u2500\u2500\u2500\n // These three carry `level` (info|warn|error) and `state` (ok|stale|broken|\n // could_not_check) in their payload, so the rule defers to the event.\n 'payments.webhook_event.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:ingest:${str(p.provider, 'all')}`,\n title: (p) => `payments ingest (${str(p.provider, 'all providers')})`,\n },\n 'payments.webhook.rejected': {\n level: 'payload', // emitted as warn/broken\n key: (p) => `payments:webhook-rejected:${str(p.provider)}`,\n title: (p) => `payments webhook rejected (${str(p.provider)})`,\n },\n // NOTE: grant.failed carries NO `provider` \u2014 it is keyed on the credit type\n // and the grant source instead (end_user_id/credit_type/amount/grant_key/\n // source/error). Keying on a field an event does not carry would collapse\n // every provider into one bucket named \"unknown\".\n 'payments.grant.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:grant:${str(p.credit_type, 'credits')}:${str(p.source, 'webhook')}`,\n title: (p) => `entitlement grant failed (${str(p.credit_type, 'credits')} via ${str(p.source, 'webhook')})`,\n },\n // Dunning. `past_due` is the red crossing and `active` is its RESOLVED twin \u2014\n // the same key, so a recovered subscription closes its own alert. Both are\n // emitted at level info, so the rule forces the level it wants.\n 'payments.subscription.past_due': {\n level: 'warn',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'broken',\n },\n 'payments.subscription.active': {\n level: 'info',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'ok',\n },\n // A dispute is a chargeback in progress \u2014 money already collected, now at risk.\n 'payments.charge.disputed': {\n level: 'warn',\n key: (p) => `payments:disputes:${str(p.provider)}`,\n title: (p) => `charge disputed (${str(p.provider)})`,\n state: 'broken',\n },\n\n // \u2500\u2500 jobs \u2014 the delivery substrate. Notification and outbound-webhook sends\n // ride jobs, so THEIR dead letters arrive here too, under `job_name`\n // ('notifications.deliver', 'webhooks.deliver', 'fn-cron:<name>', \u2026).\n // These payloads carry run_id/attempt/job_name and NO level or state, so\n // the rule supplies both. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n 'job.dead_lettered': {\n level: 'error',\n key: (p) => `jobs:dead-letter:${str(p.job_name)}`,\n title: (p) => `job dead-lettered: ${str(p.job_name)}`,\n state: 'broken',\n },\n 'job.dead_letter_quota_exceeded': {\n level: 'error',\n key: () => 'jobs:dead-letter-quota',\n title: () => 'daily dead-letter quota exceeded',\n state: 'broken',\n },\n 'job.generation.failed': {\n level: 'error',\n key: (p) => `jobs:generation:${str(p.error_class, 'failed')}`,\n title: (p) => `generation job failed (${str(p.error_class, 'unclassified')})`,\n state: 'broken',\n },\n\n // \u2500\u2500 functions \u2014 the breaker. Consecutive failures quarantine a function;\n // clearing the quarantine is the ok crossing on the SAME key, so this pair\n // produces exactly one \u{1F534} and one \u{1F7E2}. Payload: name (+ consecutive_failures).\n 'functions.quarantined': {\n level: 'error',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'broken',\n },\n 'functions.quarantine.cleared': {\n level: 'info',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'ok',\n },\n 'functions.deploy.denied': {\n level: 'warn',\n key: () => 'functions:deploy-denied',\n title: (p) => `function deploy denied (needs the ${str(p.required_tier)} tier)`,\n state: 'broken',\n },\n\n // \u2500\u2500 The lifecycle failure events (live since 2026-09-10). Every one of these\n // carries `level` + `state` in its payload (the platform's failure-event\n // vocabulary gate enforces it), so the rules defer to the event. Each\n // failure/recovery pair shares a key, so a recovery posts RESOLVED. \u2500\u2500\u2500\u2500\u2500\u2500\n 'notifications.delivery.dead_lettered': {\n level: 'payload', // emitted as error/broken; payload: delivery_id, template_id, error_class, attempts\n key: (p) => `notifications:dead-letter:${str(p.template_id, 'all')}`,\n title: (p) => `notification delivery dead-lettered (${str(p.template_id, 'all templates')})`,\n },\n 'webhooks.delivery.dead_lettered': {\n level: 'payload', // error/broken; payload: subscription_id, target_host, attempts, error_class\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.delivery.recovered': {\n level: 'payload', // info/ok \u2014 same key as the dead-letter \u2192 RESOLVED\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.inbound.rejected': {\n level: 'payload', // warn/broken; payload: source_id, provider, reason, fingerprint (never the body)\n key: (p) => `webhooks:inbound:${str(p.source_id)}:${str(p.reason)}`,\n title: (p) => `inbound webhook rejected (${str(p.provider, 'source')} ${str(p.source_id)}: ${str(p.reason)})`,\n },\n 'jobs.schedule.missed': {\n level: 'payload', // warn/STALE \u2014 a late tick is stale, not broken; payload: schedule_id, job_name, late_seconds\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n 'jobs.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED on the next on-time fire\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n // A function's cron trigger that produced NO run for 2\xD7 its interval \u2014 the\n // platform's silent-schedule watch (error/stale), and the recovery on the same\n // key. Payload: function, schedule_id, cron, last_run_at, expected_by.\n 'functions.schedule.missed': {\n level: 'payload',\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))} (last run ${str(p.last_run_at, 'never')})`,\n },\n 'functions.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED when a run appears again\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))}`,\n },\n 'functions.run.failed': {\n level: 'payload', // error/broken; payload: name, trigger, run_id, error_class\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n 'functions.run.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n};\n\n// the schedule tick, or a hand invoke (`POST /v1/fn/alerts`) carrying `{ test: true }`\ntype Envelope = CronFunctionEnvelope | HttpFunctionEnvelope<{ test?: boolean }>;\ninterface AuditRow { id: string | number; event: string; payload?: Payload; created_at?: string }\ninterface StateData {\n key: string; state?: State; level?: Level; event?: string; detail?: string;\n cursor?: string; last_event_id?: string; last_alert_at?: string; updated_at?: string;\n}\ninterface Item { item_id: string; version?: number; data: StateData }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const hook = env.secrets?.slack_webhook_url;\n const readKey = env.secrets?.vxil_read_key;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n if (!hook || !readKey) return Response.json({ error: 'missing secrets' }, { status: 409 });\n\n // Manual wiring check \u2014 posts, touches nothing else.\n if (env.payload?.test) {\n const r = await post(hook, `\u{1F7E2} [INFO] alerts-to-slack is wired (test message)`);\n return Response.json({ test: true, posted: r.ok, status: r.status });\n }\n\n const store = new Store(base, cms);\n\n // 1. the watermark (first run: start at the newest audit id \u2014 no history storm)\n const cursorRow = await store.byKey('__cursor__');\n if (!cursorRow) {\n const head = await newestAuditId(base, readKey);\n await store.create({ key: '__cursor__', cursor: head, updated_at: iso() });\n return Response.json({ initialized: true, cursor: head });\n }\n let cursor = cursorRow.data.cursor ?? '0';\n const cursorVersion = cursorRow.version;\n\n // 2\u20134. drain + evaluate\n const posts: string[] = [];\n let scanned = 0;\n let matched = 0;\n for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {\n const { rows, next } = await exportAudit(base, readKey, cursor);\n for (const row of rows) {\n scanned++;\n const rule = RULES[row.event];\n if (rule) {\n matched++;\n const msg = await evaluate(store, rule, row);\n if (msg) posts.push(msg);\n }\n cursor = String(row.id);\n }\n if (!next) break;\n cursor = next;\n }\n\n // deliver (bounded)\n let posted = 0;\n const lines = posts.slice(0, MAX_POSTS_PER_RUN);\n if (posts.length > MAX_POSTS_PER_RUN) lines.push(`\u2026 and ${posts.length - MAX_POSTS_PER_RUN} more state changes this tick`);\n for (const text of lines) {\n const r = await post(hook, text);\n if (r.ok) posted++;\n }\n\n // 5. advance the watermark \u2014 If-Match: a concurrent run that already moved it wins\n const advanced = await store.patch(cursorRow.item_id, cursorVersion, { cursor, updated_at: iso() });\n\n return Response.json({ scanned, matched, alerts: posts.length, posted, cursor, advanced });\n },\n};\n\n/** Decide, for one matched audit row, whether a message is due; persist the state. */\nasync function evaluate(store: Store, rule: Rule, row: AuditRow): Promise<string | null> {\n const p = row.payload ?? {};\n const key = rule.key(p);\n const next: State = rule.state ?? (isState(p.state) ? p.state : 'broken');\n const level: Level = rule.level === 'payload' ? (isLevel(p.level) ? p.level : 'error') : rule.level;\n const title = rule.title(p);\n const detail = str(p.reason ?? p.error ?? p.message ?? p.last_error_msg, '').slice(0, DETAIL_MAX);\n const now = iso();\n const eventId = String(row.id);\n const base: StateData = { key, state: next, level, event: row.event, detail, last_event_id: eventId, updated_at: now };\n\n const current = await store.byKey(key);\n if (!current) {\n // First sighting. A green first sighting is remembered silently \u2014 nothing was red.\n if (next === 'ok') { await store.create(base); return null; }\n const created = await store.create({ ...base, last_alert_at: now });\n return created ? format(level, next, title, detail, row.created_at) : null; // 409 = a racing run owns it\n }\n const prev = current.data.state ?? 'ok';\n if (prev !== next) {\n // A CROSSING. If-Match: exactly one concurrent run wins the transition.\n const ok = await store.patch(current.item_id, current.version, { ...base, last_alert_at: now });\n if (!ok) return null;\n return next === 'ok'\n ? `\u{1F7E2} RESOLVED: ${title} is back to ok (was ${prev})`\n : format(level, next, title, detail, row.created_at);\n }\n // Same state. Red stays quiet \u2014 unless a reminder is due.\n if (next !== 'ok' && REPEAT_AFTER_HOURS > 0) {\n const last = Date.parse(current.data.last_alert_at ?? '') || 0;\n if (Date.now() - last >= REPEAT_AFTER_HOURS * 3600e3) {\n const ok = await store.patch(current.item_id, current.version, { ...base, last_alert_at: now });\n return ok ? `\u23F0 STILL ${next.toUpperCase()}: ${title} (since ${current.data.last_alert_at ?? '?'})` : null;\n }\n }\n await store.patch(current.item_id, current.version, base); // keep detail/last_event_id fresh, no post\n return null;\n}\n\nfunction format(level: Level, state: State, title: string, detail: string, at?: string): string {\n const dot = level === 'error' ? '\u{1F534}' : level === 'warn' ? '\u{1F7E0}' : '\u{1F535}';\n return `${dot} [${level.toUpperCase()}] ${title} \u2192 ${state.toUpperCase()}${detail ? ` \u2014 ${detail}` : ''}${at ? ` (at ${at})` : ''}`;\n}\n\n// \u2500\u2500 the audit stream (ascending NDJSON export; `x-vxil-next-after-id` continues it) \u2500\u2500\nasync function exportAudit(base: string, key: string, afterId: string): Promise<{ rows: AuditRow[]; next: string | null }> {\n const res = await fetch(`${base}/v1/audit/export?after_id=${encodeURIComponent(afterId)}&limit=500`, {\n headers: { authorization: `Bearer ${key}` },\n });\n if (!res.ok) throw new Error(`audit export ${res.status}`);\n const rows = (await res.text()).split('\\n').filter(Boolean).map((l) => JSON.parse(l) as AuditRow);\n return { rows, next: res.headers.get('x-vxil-next-after-id') };\n}\nasync function newestAuditId(base: string, key: string): Promise<string> {\n const res = await fetch(`${base}/v1/audit?limit=1`, { headers: { authorization: `Bearer ${key}` } });\n if (!res.ok) throw new Error(`audit list ${res.status}`);\n const body = (await res.json()) as { data?: { events?: AuditRow[] } };\n return String(body.data?.events?.[0]?.id ?? '0');\n}\n\n// \u2500\u2500 the cms state store (the REST envelope: { data: { items:[{item_id, version, data}] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() { return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' }; }\n async byKey(key: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${this.base}/v1/cms/items/alert_state?filter=${filter}&limit=1`, { headers: this.h() });\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 (unique key already claimed by a concurrent run) */\n async create(data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/alert_state`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ data, status: 'published' }),\n });\n return res.ok;\n }\n /** false on 409 version_conflict (another run transitioned this key first) */\n async patch(itemId: string, version: number | undefined, data: Partial<StateData>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/alert_state/${itemId}`, {\n method: 'PATCH',\n headers: { ...this.h(), ...(version !== undefined ? { 'if-match': String(version) } : {}) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n\n// \u2500\u2500 the chat webhook: `text` is Slack's field, `content` is Discord's \u2014 send both \u2500\u2500\nasync function post(url: string, text: string): Promise<{ ok: boolean; status: number }> {\n const res = await fetch(url, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, content: text }),\n }).catch(() => null);\n return { ok: Boolean(res?.ok), status: res?.status ?? 0 };\n}\n\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"
16949
+ "alerts.ts": "// alerts.ts \u2014 FAILURE EVENTS \u2192 ONE CHAT MESSAGE PER STATE CROSSING (a vxil function).\n//\n// Trigger: cron `*/5 * * * *`. Each run:\n// 1. reads the watermark (the `__cursor__` row in `alert_state`; first run = \"now\",\n// so history never produces an alert storm),\n// 2. drains the tenant audit stream past it \u2014 `GET /v1/audit/export?after_id=\u2026`\n// (ascending, NDJSON) with the `vxil_read_key` secret (a key holding ONLY\n// `features:read`; the audit stream is not on the scoped callback),\n// 3. matches each row against RULES (the allow-list below) and turns it into a\n// (key, state, level) \u2014 e.g. `payments:ingest:stripe` \u2192 `broken`,\n// 4. compares with the stored state for that key (cms, one row per key, `unique`\n// key \u21D2 a racing run gets a 409 and stands down) and posts ONLY on a crossing:\n// ok\u2192red = alert, red\u2192ok = \"resolved\", red\u2192red = silence (or a reminder after\n// REPEAT_AFTER_HOURS),\n// 5. advances the watermark (If-Match on the cursor row's version \u2014 a concurrent\n// run that already advanced it wins; jobs may deliver a cron tick twice).\n//\n// Manual check: `vxil functions invoke alerts --data '{\"test\":true}'` posts a test\n// message and touches nothing else.\n//\n// Tune the constants; own the RULES table \u2014 it is data, not a routing engine.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst REPEAT_AFTER_HOURS = 24; // remind about a still-red condition this often; 0 = never\nconst MAX_PAGES_PER_RUN = 5; // \xD7 500 audit rows \u2014 bounds one cron tick\nconst MAX_POSTS_PER_RUN = 20; // a burst of distinct failures collapses into one summary line\nconst DETAIL_MAX = 240;\n\ntype Level = 'info' | 'warn' | 'error';\ntype State = 'ok' | 'stale' | 'broken' | 'could_not_check';\ntype Payload = Record<string, unknown>;\n\ninterface Rule {\n /** A literal level, or 'payload' to take `payload.level` (the payments failure\n * events carry one; the jobs/functions ones do not). */\n level: Level | 'payload';\n key: (p: Payload) => string; // one state row per key \u2014 this is what suppresses repeats\n title: (p: Payload) => string;\n /** A literal state, or omitted to take `payload.state` (falling back to 'broken'). */\n state?: State;\n}\n\nconst str = (v: unknown, fallback = 'unknown') => (typeof v === 'string' && v ? v : fallback);\n\n/** THE ALLOW-LIST. Every name below is an audit event a vxil feature writes;\n * anything not listed is ignored by the drain. The KEY is what collapses\n * repeats: ten dead letters of the same job are one key, one row, one alert.\n *\n * Every name below is emitted today (2026-09-10). A rule for an event nobody\n * emits would simply never match \u2014 so if you add one, verify the emitter first.\n */\nconst RULES: Record<string, Rule> = {\n // \u2500\u2500 payments \u2014 the money path. A silent failure here is lost revenue. \u2500\u2500\u2500\u2500\u2500\u2500\n // These three carry `level` (info|warn|error) and `state` (ok|stale|broken|\n // could_not_check) in their payload, so the rule defers to the event.\n 'payments.webhook_event.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:ingest:${str(p.provider, 'all')}`,\n title: (p) => `payments ingest (${str(p.provider, 'all providers')})`,\n },\n 'payments.webhook.rejected': {\n level: 'payload', // emitted as warn/broken\n key: (p) => `payments:webhook-rejected:${str(p.provider)}`,\n title: (p) => `payments webhook rejected (${str(p.provider)})`,\n },\n // NOTE: grant.failed carries NO `provider` \u2014 it is keyed on the credit type\n // and the grant source instead (end_user_id/credit_type/amount/grant_key/\n // source/error). Keying on a field an event does not carry would collapse\n // every provider into one bucket named \"unknown\".\n 'payments.grant.failed': {\n level: 'payload', // emitted as error/broken\n key: (p) => `payments:grant:${str(p.credit_type, 'credits')}:${str(p.source, 'webhook')}`,\n title: (p) => `entitlement grant failed (${str(p.credit_type, 'credits')} via ${str(p.source, 'webhook')})`,\n },\n // Dunning. `past_due` is the red crossing and `active` is its RESOLVED twin \u2014\n // the same key, so a recovered subscription closes its own alert. Both are\n // emitted at level info, so the rule forces the level it wants.\n 'payments.subscription.past_due': {\n level: 'warn',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'broken',\n },\n 'payments.subscription.active': {\n level: 'info',\n key: (p) => `payments:subscription:${str(p.end_user_id, 'unknown-user')}`,\n title: (p) => `subscription past due (user ${str(p.end_user_id)})`,\n state: 'ok',\n },\n // A dispute is a chargeback in progress \u2014 money already collected, now at risk.\n 'payments.charge.disputed': {\n level: 'warn',\n key: (p) => `payments:disputes:${str(p.provider)}`,\n title: (p) => `charge disputed (${str(p.provider)})`,\n state: 'broken',\n },\n\n // \u2500\u2500 jobs \u2014 the delivery substrate. Notification and outbound-webhook sends\n // ride jobs, so THEIR dead letters arrive here too, under `job_name`\n // ('notifications.deliver', 'webhooks.deliver', 'fn-cron:<name>', \u2026).\n // These payloads carry run_id/attempt/job_name and NO level or state, so\n // the rule supplies both. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n 'job.dead_lettered': {\n level: 'error',\n key: (p) => `jobs:dead-letter:${str(p.job_name)}`,\n title: (p) => `job dead-lettered: ${str(p.job_name)}`,\n state: 'broken',\n },\n 'job.dead_letter_quota_exceeded': {\n level: 'error',\n key: () => 'jobs:dead-letter-quota',\n title: () => 'daily dead-letter quota exceeded',\n state: 'broken',\n },\n 'job.generation.failed': {\n level: 'error',\n key: (p) => `jobs:generation:${str(p.error_class, 'failed')}`,\n title: (p) => `generation job failed (${str(p.error_class, 'unclassified')})`,\n state: 'broken',\n },\n\n // \u2500\u2500 functions \u2014 the breaker. Consecutive failures quarantine a function;\n // clearing the quarantine is the ok crossing on the SAME key, so this pair\n // produces exactly one \u{1F534} and one \u{1F7E2}. Payload: name (+ consecutive_failures).\n 'functions.quarantined': {\n level: 'error',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'broken',\n },\n 'functions.quarantine.cleared': {\n level: 'info',\n key: (p) => `functions:${str(p.name)}`,\n title: (p) => `function quarantined: ${str(p.name)}`,\n state: 'ok',\n },\n 'functions.deploy.denied': {\n level: 'warn',\n key: () => 'functions:deploy-denied',\n title: (p) => `function deploy denied (needs the ${str(p.required_tier)} tier)`,\n state: 'broken',\n },\n\n // \u2500\u2500 The lifecycle failure events (live since 2026-09-10). Every one of these\n // carries `level` + `state` in its payload (the platform's failure-event\n // vocabulary gate enforces it), so the rules defer to the event. Each\n // failure/recovery pair shares a key, so a recovery posts RESOLVED. \u2500\u2500\u2500\u2500\u2500\u2500\n 'notifications.delivery.dead_lettered': {\n level: 'payload', // emitted as error/broken; payload: delivery_id, template_id, error_class, attempts\n key: (p) => `notifications:dead-letter:${str(p.template_id, 'all')}`,\n title: (p) => `notification delivery dead-lettered (${str(p.template_id, 'all templates')})`,\n },\n 'webhooks.delivery.dead_lettered': {\n level: 'payload', // error/broken; payload: subscription_id, target_host, attempts, error_class\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.delivery.recovered': {\n level: 'payload', // info/ok \u2014 same key as the dead-letter \u2192 RESOLVED\n key: (p) => `webhooks:delivery:${str(p.subscription_id, 'all')}`,\n title: (p) => `outbound webhook dead-lettered (${str(p.target_host, str(p.subscription_id, 'all subscriptions'))})`,\n },\n 'webhooks.inbound.rejected': {\n level: 'payload', // warn/broken; payload: source_id, provider, reason, fingerprint (never the body)\n key: (p) => `webhooks:inbound:${str(p.source_id)}:${str(p.reason)}`,\n title: (p) => `inbound webhook rejected (${str(p.provider, 'source')} ${str(p.source_id)}: ${str(p.reason)})`,\n },\n 'jobs.schedule.missed': {\n level: 'payload', // warn/STALE \u2014 a late tick is stale, not broken; payload: schedule_id, job_name, late_seconds\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n 'jobs.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED on the next on-time fire\n key: (p) => `jobs:schedule:${str(p.schedule_id)}`,\n title: (p) => `scheduled job did not fire on time (${str(p.job_name, str(p.schedule_id))})`,\n },\n // A function's cron trigger that produced NO run for 2\xD7 its interval \u2014 the\n // platform's silent-schedule watch (error/stale), and the recovery on the same\n // key. Payload: function, schedule_id, cron, last_run_at, expected_by.\n 'functions.schedule.missed': {\n level: 'payload',\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))} (last run ${str(p.last_run_at, 'never')})`,\n },\n 'functions.schedule.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED when a run appears again\n key: (p) => `functions:schedule:${str(p.function, str(p.schedule_id))}`,\n title: (p) => `cron function stopped firing: ${str(p.function, str(p.job_name))}`,\n },\n 'functions.run.failed': {\n level: 'payload', // error/broken; payload: name, trigger, run_id, error_class\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n 'functions.run.recovered': {\n level: 'payload', // info/ok \u2014 same key \u2192 RESOLVED\n key: (p) => `functions:run:${str(p.name)}`,\n title: (p) => `function failing: ${str(p.name)} (${str(p.trigger, 'trigger')})`,\n },\n};\n\n// the schedule tick, or a hand invoke (`POST /v1/fn/alerts`) carrying `{ test: true }`\ntype Envelope = CronFunctionEnvelope | HttpFunctionEnvelope<{ test?: boolean }>;\ninterface AuditRow { id: string | number; event: string; payload?: Payload; created_at?: string }\ninterface StateData {\n key: string; state?: State; level?: Level; event?: string; detail?: string;\n cursor?: string; last_event_id?: string; last_alert_at?: string; updated_at?: string;\n}\ninterface Item { item_id: string; version?: number; data: StateData }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const hook = env.secrets?.slack_webhook_url;\n const readKey = env.secrets?.vxil_read_key;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n if (!hook || !readKey) return Response.json({ error: 'missing secrets' }, { status: 409 });\n\n // Manual wiring check \u2014 posts, touches nothing else.\n if (env.payload?.test) {\n const r = await post(hook, `\u{1F7E2} [INFO] alerts-to-slack is wired (test message)`);\n return Response.json({ test: true, posted: r.ok, status: r.status });\n }\n\n const store = new Store(base, cms);\n\n // 1. the watermark (first run: start at the newest audit id \u2014 no history storm)\n const cursorRow = await store.byKey('__cursor__');\n if (!cursorRow) {\n const head = await newestAuditId(base, readKey);\n await store.create({ key: '__cursor__', cursor: head, updated_at: iso() });\n return Response.json({ initialized: true, cursor: head });\n }\n let cursor = cursorRow.data.cursor ?? '0';\n const cursorVersion = cursorRow.version;\n\n // 2\u20134. drain + evaluate\n const posts: string[] = [];\n let scanned = 0;\n let matched = 0;\n for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {\n const { rows, next } = await exportAudit(base, readKey, cursor);\n for (const row of rows) {\n scanned++;\n const rule = RULES[row.event];\n if (rule) {\n matched++;\n const msg = await evaluate(store, rule, row);\n if (msg) posts.push(msg);\n }\n cursor = String(row.id);\n }\n if (!next) break;\n cursor = next;\n }\n\n // deliver (bounded)\n let posted = 0;\n const lines = posts.slice(0, MAX_POSTS_PER_RUN);\n if (posts.length > MAX_POSTS_PER_RUN) lines.push(`\u2026 and ${posts.length - MAX_POSTS_PER_RUN} more state changes this tick`);\n for (const text of lines) {\n const r = await post(hook, text);\n if (r.ok) posted++;\n }\n\n // 5. advance the watermark \u2014 If-Match: a concurrent run that already moved it wins\n const advanced = await store.patch(cursorRow.item_id, cursorVersion, { cursor, updated_at: iso() });\n\n return Response.json({ scanned, matched, alerts: posts.length, posted, cursor, advanced });\n },\n};\n\n/** Decide, for one matched audit row, whether a message is due; persist the state. */\nasync function evaluate(store: Store, rule: Rule, row: AuditRow): Promise<string | null> {\n const p = row.payload ?? {};\n const key = rule.key(p);\n const next: State = rule.state ?? (isState(p.state) ? p.state : 'broken');\n const level: Level = rule.level === 'payload' ? (isLevel(p.level) ? p.level : 'error') : rule.level;\n const title = rule.title(p);\n const detail = str(p.reason ?? p.error ?? p.message ?? p.last_error_msg, '').slice(0, DETAIL_MAX);\n const now = iso();\n const eventId = String(row.id);\n const base: StateData = { key, state: next, level, event: row.event, detail, last_event_id: eventId, updated_at: now };\n\n const current = await store.byKey(key);\n if (!current) {\n // First sighting. A green first sighting is remembered silently \u2014 nothing was red.\n if (next === 'ok') { await store.create(base); return null; }\n const created = await store.create({ ...base, last_alert_at: now });\n return created ? format(level, next, title, detail, row.created_at) : null; // 409 = a racing run owns it\n }\n const prev = current.data.state ?? 'ok';\n if (prev !== next) {\n // A CROSSING. If-Match: exactly one concurrent run wins the transition.\n const ok = await store.patch(current.item_id, current.version, { ...base, last_alert_at: now });\n if (!ok) return null;\n return next === 'ok'\n ? `\u{1F7E2} RESOLVED: ${title} is back to ok (was ${prev})`\n : format(level, next, title, detail, row.created_at);\n }\n // Same state. Red stays quiet \u2014 unless a reminder is due.\n if (next !== 'ok' && REPEAT_AFTER_HOURS > 0) {\n const last = Date.parse(current.data.last_alert_at ?? '') || 0;\n if (Date.now() - last >= REPEAT_AFTER_HOURS * 3600e3) {\n const ok = await store.patch(current.item_id, current.version, { ...base, last_alert_at: now });\n return ok ? `\u23F0 STILL ${next.toUpperCase()}: ${title} (since ${current.data.last_alert_at ?? '?'})` : null;\n }\n }\n await store.patch(current.item_id, current.version, base); // keep detail/last_event_id fresh, no post\n return null;\n}\n\nfunction format(level: Level, state: State, title: string, detail: string, at?: string): string {\n const dot = level === 'error' ? '\u{1F534}' : level === 'warn' ? '\u{1F7E0}' : '\u{1F535}';\n return `${dot} [${level.toUpperCase()}] ${title} \u2192 ${state.toUpperCase()}${detail ? ` \u2014 ${detail}` : ''}${at ? ` (at ${at})` : ''}`;\n}\n\n// \u2500\u2500 the audit stream (ascending NDJSON export; `x-vxil-next-after-id` continues it) \u2500\u2500\nasync function exportAudit(base: string, key: string, afterId: string): Promise<{ rows: AuditRow[]; next: string | null }> {\n const res = await fetch(`${base}/v1/audit/export?after_id=${encodeURIComponent(afterId)}&limit=500`, {\n headers: { authorization: `Bearer ${key}` },\n });\n if (!res.ok) throw new Error(`audit export ${res.status}`);\n const rows = (await res.text()).split('\\n').filter(Boolean).map((l) => JSON.parse(l) as AuditRow);\n return { rows, next: res.headers.get('x-vxil-next-after-id') };\n}\nasync function newestAuditId(base: string, key: string): Promise<string> {\n const res = await fetch(`${base}/v1/audit?limit=1`, { headers: { authorization: `Bearer ${key}` } });\n if (!res.ok) throw new Error(`audit list ${res.status}`);\n const body = (await res.json()) as { data?: { events?: AuditRow[] } };\n return String(body.data?.events?.[0]?.id ?? '0');\n}\n\n// \u2500\u2500 the cms state store (the REST envelope: { data: { items:[{item_id, version, data}] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() { return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' }; }\n async byKey(key: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${this.base}/v1/cms/items/alert_state?filter=${filter}&limit=1`, { headers: this.h() });\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 (unique key already claimed by a concurrent run) */\n async create(data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/alert_state`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ data, status: 'published' }),\n });\n return res.ok;\n }\n /** false on 409 version_conflict (another run transitioned this key first) */\n async patch(itemId: string, version: number | undefined, data: Partial<StateData>): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/alert_state/${itemId}`, {\n method: 'PATCH',\n headers: { ...this.h(), ...(version !== undefined ? { 'if-match': String(version) } : {}) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n\n// \u2500\u2500 the chat webhook: `text` is Slack's field, `content` is Discord's \u2014 send both,\n// EXCEPT to a Google Chat space webhook (chat.googleapis.com), which takes\n// `{ text }` alone \u2014 an unknown field can be rejected there, so it is dropped.\nasync function post(url: string, text: string): Promise<{ ok: boolean; status: number }> {\n let host = '';\n try { host = new URL(url).hostname; } catch { /* unparseable \u2192 the generic body below */ }\n const body = host === 'chat.googleapis.com' ? { text } : { text, content: text };\n const res = await fetch(url, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n }).catch(() => null);\n return { ok: Boolean(res?.ok), status: res?.status ?? 0 };\n}\n\nconst iso = () => new Date().toISOString();\nconst isState = (v: unknown): v is State => v === 'ok' || v === 'stale' || v === 'broken' || v === 'could_not_check';\nconst isLevel = (v: unknown): v is Level => v === 'info' || v === 'warn' || v === 'error';\n"
16118
16950
  }
16119
16951
  },
16120
16952
  {
@@ -16134,10 +16966,10 @@ const json = (o: unknown, status: number) => Response.json(o, { status });
16134
16966
  "byoKeys": [
16135
16967
  "slack_webhook_url"
16136
16968
  ],
16137
- "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// \"Payments heartbeat\" \u2014 THE SILENT-PROVIDER DETECTOR.\n//\n// Every other payments check answers \"did THIS event fold correctly?\". This one\n// answers the question nobody asks until it is too late: \"has my provider sent\n// me ANYTHING lately?\" A provider that stops posting produces no errors, no\n// failed webhooks, no red dashboard \u2014 just silence, and silence looks exactly\n// like a quiet week. This blueprint turns silence into a signal:\n// \u2022 payments \u2192 the webhook-event log (`GET /v1/payments/webhook-events`) is\n// already the record of every delivery your integration folded\n// \u2022 cms \u2192 `heartbeat_state`: one row per provider, so an alert fires on\n// the CROSSING and not once a day forever\n// \u2022 functions \u2192 `heartbeat`: a daily cron that measures days-since-last-\n// processed-event against YOUR OWN cadence and posts to Slack\n//\n// The threshold is not a number somebody picked: it is 3 \xD7 your median\n// inter-event gap, with a 7-day floor. A 100-subscriber app and a 100 000-\n// subscriber app have wildly different \"normal\", and a fixed number of days is\n// wrong for both.\n//\n// \u26A0 THIS IS AN OVERLAY, NOT A FRESH BACKEND. `vxil push` replaces a feature's\n// config wholesale. If you already run the payments feature, copy the\n// `heartbeat_state` collection and the `heartbeat` function into your EXISTING\n// vxil.config.ts rather than pushing this file \u2014 see README.md.\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\n // Your payments INTEGRATION \u2014 your own provider account, your own keys;\n // vxil folds the lifecycle webhooks into a ledger and an entitlement\n // snapshot and is never in the flow of funds. Shown here so the file is a\n // coherent whole; if you already have a payments block, keep YOURS.\n payments: {\n provider: 'stripe',\n stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' },\n defaults: { currency: 'usd' },\n },\n\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n // The heartbeat's memory: ONE row per provider. `provider` is unique, so\n // two overlapping cron ticks cannot both create it (the loser gets 409).\n heartbeat_state: {\n singular: 'heartbeat_state',\n fields: {\n provider: { type: 'string', required: true, indexSlot: 's1', unique: true },\n state: { type: 'string', indexSlot: 's2' }, // ok | stale | broken | could_not_check\n detail: { type: 'text' }, // the human sentence that was (or would be) posted\n days_since: { type: 'float', indexSlot: 'n1' }, // days since the last processed event\n threshold_days: { type: 'float', indexSlot: 'n2' }, // max(7, 3 \xD7 median gap)\n last_event_at: { type: 'datetime', indexSlot: 't1' },\n checked_at: { type: 'datetime', indexSlot: 't2' },\n last_event_id: { type: 'string', indexSlot: 's3' },\n },\n },\n },\n },\n\n functions: {\n // Once a day is the right cadence for a check measured in DAYS. Also\n // invocable by hand \u2014 `vxil functions invoke heartbeat` \u2014 which runs the\n // real check and returns the per-provider verdict as JSON without posting\n // anything unless a state actually crossed.\n heartbeat: {\n entry: './functions/heartbeat.ts',\n trigger: { kind: 'cron', schedule: '0 7 * * *' },\n // payments:read = the webhook-event log; cms:* = the state rows. No write\n // scope on payments: a monitor must never be able to move money-adjacent\n // records, and this one structurally cannot.\n scopes: ['payments:read', 'cms:read', 'cms:write'],\n secrets: ['secret:slack_webhook_url'],\n egressAllow: ['hooks.slack.com'], // add 'discord.com' for a Discord webhook\n },\n },\n\n secrets: {\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord webhook) URL' },\n stripe_secret: { feature: 'payments', description: 'Stripe secret key (BYO \u2014 your own account)' },\n stripe_webhook: { feature: 'payments', description: 'Stripe webhook signing secret' },\n },\n});\n",
16138
- "readme": '# Payments heartbeat (ops)\n\nEvery other payments check answers *"did this event fold correctly?"*. This one answers the question\nnobody asks until it is too late: **"has my provider sent me anything lately?"**\n\nA provider that stops posting produces no errors, no failed webhooks, no red dashboard \u2014 just silence,\nand silence looks exactly like a quiet week. One daily cron function measures **days since the last\nprocessed production event, per provider**, against **your own cadence**, keeps a three-state\n`ok | stale | broken` record in `cms`, and posts to Slack (or Discord) **once per crossing**.\n\n```bash\nvxil init --template payments-heartbeat\nvxil quickstart # or `vxil link <slug>` for an existing backend\nprintf \'%s\' "$SLACK_URL" | vxil secrets set functions/slack_webhook_url\nvxil push # the heartbeat_state collection + the cron function\nvxil functions invoke heartbeat # run the real check now; prints the per-provider verdict\n```\n\n> \u26A0 **This blueprint is an overlay.** `vxil push` writes each feature\'s config as a whole version, so\n> pushing this file at a backend that already runs payments would replace your payments block with the\n> example one below. If you already have a `payments` integration configured, copy just the\n> **`heartbeat_state` collection** and the **`heartbeat` function** into your existing `vxil.config.ts`\n> and push that. On a fresh backend, push this file as-is and edit the payments block to your provider.\n\n## The threshold is derived, not guessed\n\nA fixed "alert after 14 days" is wrong for a 100-subscriber app *and* for a 100 000-subscriber one. So the\nfunction computes it from your own traffic:\n\n```\nthreshold_days = max( 7 , 3 \xD7 median gap between your last 100 processed events )\n```\n\n- **`3 \xD7` the median gap** \u2014 the median, not the mean, because one migration backfill or one Black Friday\n would inflate a mean for months. Three is the "this is no longer a quiet week" multiple.\n- **A 7-day floor** \u2014 a low-volume integration can legitimately go a week without a single lifecycle\n event, and alerting on that is how a channel gets muted.\n- **Fewer than 5 gaps sampled** \u2192 there is no meaningful cadence yet, so the floor is used on its own.\n- Past **2 \xD7** the threshold, `stale` becomes `broken`. That severity split is this blueprint\'s choice \u2014\n tune `BROKEN_MULTIPLE` in `functions/heartbeat.ts`; every constant at the top of that file is policy.\n\n`ALERT_MULTIPLE = 3` and `FLOOR_DAYS = 7` are the numbers the platform\'s own payments-ingest SLO uses for\nits silent-provider detection SLI, so the blueprint and the platform agree by construction.\n\n## What it reads\n\n```\nGET /v1/payments/webhook-events?provider=<p>&environment=production&outcome=processed&limit=100\n```\n\nwith the function\'s `payments:read` scope \u2014 the webhook event log your integration already writes for\nevery delivery it folds. Three details in that URL are the whole design:\n\n| Part | Why |\n|---|---|\n| `provider=<p>` | asked **per provider**, in a loop. A global query would be dominated by your chattiest provider, and a quiet one that went silent months ago would never appear in the newest page at all. |\n| `environment=production` | sandbox traffic is developer noise. A provider can be chatty in test mode while production has been silent for a month \u2014 exactly the outage this exists to catch. |\n| `outcome=processed` | not merely *received*. An event that arrived and never folded is a different failure (`payments.webhook_event.failed`, which `templates/alerts-to-slack/` picks up); this function is about arrival. |\n\nA provider that has **never** delivered a processed production event is skipped entirely \u2014 there is no\ncadence to be silent against. Edit `PROVIDERS` in the function to match the ones you actually use.\n\n## States and messages\n\n| State | When | Message |\n|---|---|---|\n| `ok` | `days_since \u2264 threshold` | (silent \u2014 or \u{1F7E2} RESOLVED if it was not ok before) |\n| `stale` | `threshold < days_since \u2264 2 \xD7 threshold` | \u{1F7E0} `[STALE] payments heartbeat \u2014 \u2026` |\n| `broken` | `days_since > 2 \xD7 threshold` | \u{1F534} `[BROKEN] payments heartbeat \u2014 \u2026` |\n| `could_not_check` | the event-log read itself failed (feature disabled, scope missing, network) | \u{1F535} `[CHECK FAILED] \u2026` \u2014 a monitor that cannot see is not a monitor that says "fine" |\n\n```\n\u{1F534} [BROKEN] payments heartbeat \u2014 stripe has sent no processed production event for 23.4d \u2014\n expected one within 7d (median gap 1.2d \xD7 3, floor 7d)\n\u{1F7E2} RESOLVED: stripe is delivering again \u2014 last processed event 0.1d ago (was broken)\n```\n\n## Once per crossing\n\nOne `heartbeat_state` row per provider, `provider` declared `unique`. Cron delivery is at-least-once, so\ntwo ticks can overlap: the unique field means only one can *create* the row (the other gets `409`), and\nevery update carries `If-Match: <version>`, so only one can *transition* it (the other gets `409\nversion_conflict` and stands down). A provider that stays broken for a month costs exactly **one**\nmessage, not thirty \u2014 and the row keeps `days_since` / `threshold_days` / `last_event_at` fresh the\nwhole time, so the dashboard always shows the current measurement even while the channel is quiet.\n\n## The scopes\n\n`payments:read`, `cms:read`, `cms:write` \u2014 and deliberately **no** payments write scope. A monitor should\nnot be able to touch money-adjacent records, and this one structurally cannot: the function\'s callback\ntoken is minted from exactly these scopes, so there is no write path to reach for.\n\nEgress is deny-by-default: `egressAllow: [\'hooks.slack.com\']`. Add `\'discord.com\'` for a Discord webhook \u2014\nthe message body carries both Slack\'s `text` and Discord\'s `content`, so one payload works on either.\n\n## What to learn from this\n\n- **A monitor\'s threshold should come from the system it monitors.** Reading your own median gap turns\n one alert rule into a rule that fits every tenant.\n- **Absence is the hardest signal.** Nothing emits an event when a provider goes quiet, so the check has\n to be a *sweep*, not a subscription \u2014 which is what cron functions are for.\n- **`could_not_check` is a real state.** Folding "I could not look" into "everything is fine" is how\n monitoring dies silently; giving it its own state is one line and saves an outage.\n\n**Pairs with:** `templates/alerts-to-slack/` \u2014 the same suppression pattern applied to failure *events*\nrather than to silence, so together they cover both halves of the money path. The two ship the same\nSlack/Discord formatter on purpose: each blueprint is a self-contained clone, and every file under a\ntemplate\'s `functions/` directory must be a declared entry point, so there is nowhere for a shared module\nto live. Copy the file, own the copy.\n',
16969
+ "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// \"Payments heartbeat\" \u2014 THE SILENT-PROVIDER DETECTOR.\n//\n// Every other payments check answers \"did THIS event fold correctly?\". This one\n// answers the question nobody asks until it is too late: \"has my provider sent\n// me ANYTHING lately?\" A provider that stops posting produces no errors, no\n// failed webhooks, no red dashboard \u2014 just silence, and silence looks exactly\n// like a quiet week. This blueprint turns silence into a signal:\n// \u2022 payments \u2192 the webhook-event log (`GET /v1/payments/webhook-events`) is\n// already the record of every delivery your integration folded\n// \u2022 cms \u2192 `heartbeat_state`: one row per provider, so an alert fires on\n// the CROSSING and not once a day forever\n// \u2022 functions \u2192 `heartbeat`: a daily cron that measures days-since-last-\n// processed-event against YOUR OWN cadence and posts to Slack\n//\n// The threshold is not a number somebody picked: it is 3 \xD7 your median\n// inter-event gap, with a 7-day floor. A 100-subscriber app and a 100 000-\n// subscriber app have wildly different \"normal\", and a fixed number of days is\n// wrong for both.\n//\n// \u26A0 THIS IS AN OVERLAY, NOT A FRESH BACKEND. `vxil push` replaces a feature's\n// config wholesale. If you already run the payments feature, copy the\n// `heartbeat_state` collection and the `heartbeat` function into your EXISTING\n// vxil.config.ts rather than pushing this file \u2014 see README.md.\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\n // Your payments INTEGRATION \u2014 your own provider account, your own keys;\n // vxil folds the lifecycle webhooks into a ledger and an entitlement\n // snapshot and is never in the flow of funds. Shown here so the file is a\n // coherent whole; if you already have a payments block, keep YOURS.\n payments: {\n provider: 'stripe',\n stripe: { secretKeyRef: 'stripe_secret', webhookSecretRef: 'stripe_webhook' },\n defaults: { currency: 'usd' },\n },\n\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n // The heartbeat's memory: ONE row per provider. `provider` is unique, so\n // two overlapping cron ticks cannot both create it (the loser gets 409).\n heartbeat_state: {\n singular: 'heartbeat_state',\n fields: {\n provider: { type: 'string', required: true, indexSlot: 's1', unique: true },\n state: { type: 'string', indexSlot: 's2' }, // ok | stale | broken | could_not_check\n detail: { type: 'text' }, // the human sentence that was (or would be) posted\n days_since: { type: 'float', indexSlot: 'n1' }, // days since the last processed event\n threshold_days: { type: 'float', indexSlot: 'n2' }, // max(7, 3 \xD7 median gap)\n last_event_at: { type: 'datetime', indexSlot: 't1' },\n checked_at: { type: 'datetime', indexSlot: 't2' },\n last_event_id: { type: 'string', indexSlot: 's3' },\n },\n },\n },\n },\n\n functions: {\n // Once a day is the right cadence for a check measured in DAYS. Also\n // invocable by hand \u2014 `vxil functions invoke heartbeat` \u2014 which runs the\n // real check and returns the per-provider verdict as JSON without posting\n // anything unless a state actually crossed.\n heartbeat: {\n entry: './functions/heartbeat.ts',\n trigger: { kind: 'cron', schedule: '0 7 * * *' },\n // payments:read = the webhook-event log; cms:* = the state rows. No write\n // scope on payments: a monitor must never be able to move money-adjacent\n // records, and this one structurally cannot.\n scopes: ['payments:read', 'cms:read', 'cms:write'],\n secrets: ['secret:slack_webhook_url'],\n egressAllow: ['hooks.slack.com'], // add 'discord.com' for Discord, 'chat.googleapis.com' for Google Chat\n },\n },\n\n secrets: {\n slack_webhook_url: { feature: 'functions', description: 'Slack incoming webhook (or Discord / Google Chat space webhook) URL' },\n stripe_secret: { feature: 'payments', description: 'Stripe secret key (BYO \u2014 your own account)' },\n stripe_webhook: { feature: 'payments', description: 'Stripe webhook signing secret' },\n },\n});\n",
16970
+ "readme": "# Payments heartbeat (ops)\n\nEvery other payments check answers *\"did this event fold correctly?\"*. This one answers the question\nnobody asks until it is too late: **\"has my provider sent me anything lately?\"**\n\nA provider that stops posting produces no errors, no failed webhooks, no red dashboard \u2014 just silence,\nand silence looks exactly like a quiet week. One daily cron function measures **days since the last\nprocessed production event, per provider**, against **your own cadence**, keeps a three-state\n`ok | stale | broken` record in `cms`, and posts to Slack (or Discord, or Google Chat) **once per crossing**.\n\n```bash\nvxil init --template payments-heartbeat\nvxil quickstart # or `vxil link <slug>` for an existing backend\nprintf '%s' \"$SLACK_URL\" | vxil secrets set functions/slack_webhook_url\nvxil push # the heartbeat_state collection + the cron function\nvxil functions invoke heartbeat # run the real check now; prints the per-provider verdict\n```\n\n> \u26A0 **This blueprint is an overlay.** `vxil push` writes each feature's config as a whole version, so\n> pushing this file at a backend that already runs payments would replace your payments block with the\n> example one below. If you already have a `payments` integration configured, copy just the\n> **`heartbeat_state` collection** and the **`heartbeat` function** into your existing `vxil.config.ts`\n> and push that. On a fresh backend, push this file as-is and edit the payments block to your provider.\n\n## The threshold is derived, not guessed\n\nA fixed \"alert after 14 days\" is wrong for a 100-subscriber app *and* for a 100 000-subscriber one. So the\nfunction computes it from your own traffic:\n\n```\nthreshold_days = max( 7 , 3 \xD7 median gap between your last 100 processed events )\n```\n\n- **`3 \xD7` the median gap** \u2014 the median, not the mean, because one migration backfill or one Black Friday\n would inflate a mean for months. Three is the \"this is no longer a quiet week\" multiple.\n- **A 7-day floor** \u2014 a low-volume integration can legitimately go a week without a single lifecycle\n event, and alerting on that is how a channel gets muted.\n- **Fewer than 5 gaps sampled** \u2192 there is no meaningful cadence yet, so the floor is used on its own.\n- Past **2 \xD7** the threshold, `stale` becomes `broken`. That severity split is this blueprint's choice \u2014\n tune `BROKEN_MULTIPLE` in `functions/heartbeat.ts`; every constant at the top of that file is policy.\n\n`ALERT_MULTIPLE = 3` and `FLOOR_DAYS = 7` are the numbers the platform's own payments-ingest SLO uses for\nits silent-provider detection SLI, so the blueprint and the platform agree by construction.\n\n## What it reads\n\n```\nGET /v1/payments/webhook-events?provider=<p>&environment=production&outcome=processed&limit=100\n```\n\nwith the function's `payments:read` scope \u2014 the webhook event log your integration already writes for\nevery delivery it folds. Three details in that URL are the whole design:\n\n| Part | Why |\n|---|---|\n| `provider=<p>` | asked **per provider**, in a loop. A global query would be dominated by your chattiest provider, and a quiet one that went silent months ago would never appear in the newest page at all. |\n| `environment=production` | sandbox traffic is developer noise. A provider can be chatty in test mode while production has been silent for a month \u2014 exactly the outage this exists to catch. |\n| `outcome=processed` | not merely *received*. An event that arrived and never folded is a different failure (`payments.webhook_event.failed`, which `templates/alerts-to-slack/` picks up); this function is about arrival. |\n\nA provider that has **never** delivered a processed production event is skipped entirely \u2014 there is no\ncadence to be silent against. Edit `PROVIDERS` in the function to match the ones you actually use.\n\n## States and messages\n\n| State | When | Message |\n|---|---|---|\n| `ok` | `days_since \u2264 threshold` | (silent \u2014 or \u{1F7E2} RESOLVED if it was not ok before) |\n| `stale` | `threshold < days_since \u2264 2 \xD7 threshold` | \u{1F7E0} `[STALE] payments heartbeat \u2014 \u2026` |\n| `broken` | `days_since > 2 \xD7 threshold` | \u{1F534} `[BROKEN] payments heartbeat \u2014 \u2026` |\n| `could_not_check` | the event-log read itself failed (feature disabled, scope missing, network) | \u{1F535} `[CHECK FAILED] \u2026` \u2014 a monitor that cannot see is not a monitor that says \"fine\" |\n\n```\n\u{1F534} [BROKEN] payments heartbeat \u2014 stripe has sent no processed production event for 23.4d \u2014\n expected one within 7d (median gap 1.2d \xD7 3, floor 7d)\n\u{1F7E2} RESOLVED: stripe is delivering again \u2014 last processed event 0.1d ago (was broken)\n```\n\n## Once per crossing\n\nOne `heartbeat_state` row per provider, `provider` declared `unique`. Cron delivery is at-least-once, so\ntwo ticks can overlap: the unique field means only one can *create* the row (the other gets `409`), and\nevery update carries `If-Match: <version>`, so only one can *transition* it (the other gets `409\nversion_conflict` and stands down). A provider that stays broken for a month costs exactly **one**\nmessage, not thirty \u2014 and the row keeps `days_since` / `threshold_days` / `last_event_at` fresh the\nwhole time, so the dashboard always shows the current measurement even while the channel is quiet.\n\n## The scopes\n\n`payments:read`, `cms:read`, `cms:write` \u2014 and deliberately **no** payments write scope. A monitor should\nnot be able to touch money-adjacent records, and this one structurally cannot: the function's callback\ntoken is minted from exactly these scopes, so there is no write path to reach for.\n\nEgress is deny-by-default: `egressAllow: ['hooks.slack.com']`. Add `'discord.com'` for a Discord webhook \u2014\nthe message body carries both Slack's `text` and Discord's `content`, so one payload works on either.\nAdd `'chat.googleapis.com'` for a Google Chat space webhook \u2014 that host takes `text` alone, and the function\nsends only that field to it.\n\n## What to learn from this\n\n- **A monitor's threshold should come from the system it monitors.** Reading your own median gap turns\n one alert rule into a rule that fits every tenant.\n- **Absence is the hardest signal.** Nothing emits an event when a provider goes quiet, so the check has\n to be a *sweep*, not a subscription \u2014 which is what cron functions are for.\n- **`could_not_check` is a real state.** Folding \"I could not look\" into \"everything is fine\" is how\n monitoring dies silently; giving it its own state is one line and saves an outage.\n\n**Pairs with:** `templates/alerts-to-slack/` \u2014 the same suppression pattern applied to failure *events*\nrather than to silence, so together they cover both halves of the money path. The two ship the same\nSlack/Discord formatter on purpose: each blueprint is a self-contained clone, and every file under a\ntemplate's `functions/` directory must be a declared entry point, so there is nowhere for a shared module\nto live. Copy the file, own the copy.\n",
16139
16971
  "functions": {
16140
- "heartbeat.ts": "// heartbeat.ts \u2014 THE SILENT-PROVIDER DETECTOR (a vxil function, cron trigger).\n//\n// Trigger: cron `0 7 * * *`. For each provider you list in PROVIDERS:\n// 1. read the newest PROCESSED production webhook events \u2014\n// `GET /v1/payments/webhook-events?provider=<p>&environment=production\n// &outcome=processed&limit=100` (newest first),\n// 2. days_since = now \u2212 the newest event's received_at,\n// 3. threshold = max(FLOOR_DAYS, ALERT_MULTIPLE \xD7 the MEDIAN gap between\n// those events) \u2014 your own cadence, not a number somebody picked,\n// 4. ok | stale | broken (or could_not_check when the read itself failed),\n// 5. compare with the stored row for that provider and post to Slack ONLY on\n// a crossing; a return to ok posts RESOLVED.\n//\n// Why the median and not the mean: one migration backfill or one Black Friday\n// inflates a mean for months. The median is what \"normal\" actually looks like.\n//\n// Why `?environment=production`: sandbox traffic is developer noise. A provider\n// can be chatty in test mode while production has been silent for a month \u2014\n// that is precisely the outage this function exists to catch.\n//\n// Run it by hand any time: `vxil functions invoke heartbeat`. It performs the\n// real check and returns the per-provider verdict as JSON; it still only posts\n// if a state genuinely crossed.\n\n// \u2500\u2500 Tune these. They are the whole policy. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\n/** Providers to probe. Must be names the payments API accepts. Drop the ones\n * you do not use \u2014 a provider that has never sent an event is skipped anyway. */\nconst PROVIDERS = ['stripe', 'paddle', 'paypal', 'revenuecat'] as const;\n/** Alert once silence exceeds this multiple of your median inter-event gap. */\nconst ALERT_MULTIPLE = 3;\n/** \u2026but never sooner than this many days, however chatty your integration is.\n * A low-volume app can legitimately go a week without a single lifecycle event. */\nconst FLOOR_DAYS = 7;\n/** Past this multiple of the threshold, `stale` becomes `broken`. This split is\n * the blueprint's own choice \u2014 the SLO defines the alert threshold, not the\n * severity ladder \u2014 so move it wherever your escalation wants it. */\nconst BROKEN_MULTIPLE = 2;\n/** Gaps needed before a median means anything. Below this the floor is used. */\nconst MIN_GAPS_FOR_MEDIAN = 5;\n/** Events sampled per provider (the API caps a page at 100). */\nconst SAMPLE = 100;\n\ntype State = 'ok' | 'stale' | 'broken' | 'could_not_check';\n\ntype Envelope = CronFunctionEnvelope;\ninterface WebhookEvent { event_id: string; provider: string; received_at: string | null }\ninterface StateData {\n provider: string; state?: State; detail?: string;\n days_since?: number; threshold_days?: number;\n last_event_at?: string; checked_at?: string; last_event_id?: string;\n}\ninterface Item { item_id: string; version?: number; data: StateData }\n\ninterface Verdict {\n provider: string;\n state: State;\n detail: string;\n days_since: number | null;\n threshold_days: number | null;\n last_event_at: string | null;\n last_event_id: string | null;\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const payments = env.scoped_jwts?.payments;\n const cms = env.scoped_jwts?.cms;\n const hook = env.secrets?.slack_webhook_url;\n if (!payments || !cms) return Response.json({ error: 'missing payments:read / cms scope' }, { status: 403 });\n if (!hook) return Response.json({ error: 'missing secret slack_webhook_url' }, { status: 409 });\n\n const store = new Store(base, cms);\n const checked: Verdict[] = [];\n const crossings: string[] = [];\n\n for (const provider of PROVIDERS) {\n const verdict = await check(base, payments, provider);\n if (!verdict) continue; // never sent an event \u2192 not part of this integration\n checked.push(verdict);\n const line = await reconcile(store, verdict);\n if (line) crossings.push(line);\n }\n\n let posted = 0;\n for (const text of crossings) {\n const r = await post(hook, text);\n if (r.ok) posted++;\n }\n return Response.json({ checked, crossings: crossings.length, posted });\n },\n};\n\n/** Measure one provider. `null` = the provider has never delivered a processed\n * production event, so there is no cadence to be silent against \u2014 the SLO's\n * \"once the tenant has ever received one\" precondition. */\nasync function check(base: string, jwt: string, provider: string): Promise<Verdict | null> {\n const url = `${base}/v1/payments/webhook-events`\n + `?provider=${encodeURIComponent(provider)}&environment=production&outcome=processed&limit=${SAMPLE}`;\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } }).catch(() => null);\n if (!res) {\n return verdict(provider, 'could_not_check', `could not reach the payments event log for ${provider}`);\n }\n if (!res.ok) {\n // 501 capability_not_enabled / 403 missing scope are configuration, not silence.\n return verdict(provider, 'could_not_check', `payments event log returned ${res.status} for ${provider}`);\n }\n const body = (await res.json().catch(() => ({}))) as { data?: { events?: WebhookEvent[] } };\n const events = (body.data?.events ?? []).filter((e) => e.received_at);\n if (events.length === 0) return null;\n\n // The API orders by received_at DESC, so [0] is the newest.\n const times = events.map((e) => Date.parse(e.received_at!)).filter(Number.isFinite).sort((a, b) => b - a);\n if (times.length === 0) return null;\n const last = times[0]!;\n const daysSince = round2((Date.now() - last) / 86_400_000);\n\n const gaps: number[] = [];\n for (let i = 0; i + 1 < times.length; i++) gaps.push((times[i]! - times[i + 1]!) / 86_400_000);\n const medianGap = gaps.length >= MIN_GAPS_FOR_MEDIAN ? median(gaps) : null;\n const threshold = round2(Math.max(FLOOR_DAYS, medianGap === null ? 0 : ALERT_MULTIPLE * medianGap));\n\n const state: State = daysSince <= threshold ? 'ok'\n : daysSince <= threshold * BROKEN_MULTIPLE ? 'stale'\n : 'broken';\n\n const cadence = medianGap === null\n ? `only ${gaps.length} gap(s) sampled \u2014 using the ${FLOOR_DAYS}-day floor`\n : `median gap ${round2(medianGap)}d \xD7 ${ALERT_MULTIPLE}, floor ${FLOOR_DAYS}d`;\n const detail = state === 'ok'\n ? `${provider}: last processed event ${daysSince}d ago (threshold ${threshold}d \u2014 ${cadence})`\n : `${provider} has sent no processed production event for ${daysSince}d \u2014 expected one within ${threshold}d (${cadence})`;\n\n return {\n provider, state, detail,\n days_since: daysSince,\n threshold_days: threshold,\n last_event_at: new Date(last).toISOString(),\n last_event_id: events[0]!.event_id ?? null,\n };\n}\n\n/** Persist the verdict; return a message ONLY when the state crossed. */\nasync function reconcile(store: Store, v: Verdict): Promise<string | null> {\n const now = new Date().toISOString();\n const data: StateData = {\n provider: v.provider, state: v.state, detail: v.detail, checked_at: now,\n ...(v.days_since !== null ? { days_since: v.days_since } : {}),\n ...(v.threshold_days !== null ? { threshold_days: v.threshold_days } : {}),\n ...(v.last_event_at ? { last_event_at: v.last_event_at } : {}),\n ...(v.last_event_id ? { last_event_id: v.last_event_id } : {}),\n };\n\n const current = await store.byProvider(v.provider);\n if (!current) {\n // First run. A healthy first sighting is remembered silently; an unhealthy\n // one is worth saying out loud immediately \u2014 you were already in the outage.\n const created = await store.create(data);\n return created && v.state !== 'ok' ? format(v) : null; // 409 \u21D2 a racing tick owns it\n }\n\n const prev = current.data.state ?? 'ok';\n if (prev === v.state) {\n // No crossing: refresh the measurement, stay quiet. A permanently-broken\n // provider therefore costs exactly one message, not one per day.\n await store.patch(current.item_id, current.version, data);\n return null;\n }\n // A CROSSING. If-Match makes exactly one of two overlapping ticks the winner.\n const ok = await store.patch(current.item_id, current.version, data);\n if (!ok) return null;\n return v.state === 'ok'\n ? `\u{1F7E2} RESOLVED: ${v.provider} is delivering again \u2014 last processed event ${v.days_since}d ago (was ${prev})`\n : format(v);\n}\n\nfunction format(v: Verdict): string {\n const dot = v.state === 'broken' ? '\u{1F534}' : v.state === 'stale' ? '\u{1F7E0}' : '\u{1F535}';\n const label = v.state === 'could_not_check' ? 'CHECK FAILED' : v.state.toUpperCase();\n return `${dot} [${label}] payments heartbeat \u2014 ${v.detail}`;\n}\n\n// \u2500\u2500 the cms state store (the REST envelope: { data: { items: [{item_id, version, data}] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() { return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' }; }\n async byProvider(provider: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ provider }));\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state?filter=${filter}&limit=1`, { headers: this.h() });\n if (!res.ok) return null;\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 \u2014 `provider` is unique, so a concurrent tick already claimed it. */\n async create(data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ status: 'published', data }),\n });\n return res.ok;\n }\n /** false on 409 version_conflict \u2014 another tick transitioned this provider first. */\n async patch(itemId: string, version: number | undefined, data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state/${itemId}`, {\n method: 'PATCH',\n headers: { ...this.h(), ...(version !== undefined ? { 'if-match': String(version) } : {}) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n\n// \u2500\u2500 the chat webhook: `text` is Slack's field, `content` is Discord's \u2014 send both.\n// (Duplicated from templates/alerts-to-slack on purpose: a blueprint is a\n// self-contained clone, and every file under functions/ must be a declared\n// entry point, so there is no place for a shared module to live.) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function post(url: string, text: string): Promise<{ ok: boolean; status: number }> {\n const res = await fetch(url, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, content: text }),\n }).catch(() => null);\n return { ok: Boolean(res?.ok), status: res?.status ?? 0 };\n}\n\nfunction verdict(provider: string, state: State, detail: string): Verdict {\n return { provider, state, detail, days_since: null, threshold_days: null, last_event_at: null, last_event_id: null };\n}\nfunction median(xs: number[]): number {\n const s = [...xs].sort((a, b) => a - b);\n const mid = s.length >> 1;\n return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2;\n}\nconst round2 = (n: number) => Math.round(n * 100) / 100;\n"
16972
+ "heartbeat.ts": "// heartbeat.ts \u2014 THE SILENT-PROVIDER DETECTOR (a vxil function, cron trigger).\n//\n// Trigger: cron `0 7 * * *`. For each provider you list in PROVIDERS:\n// 1. read the newest PROCESSED production webhook events \u2014\n// `GET /v1/payments/webhook-events?provider=<p>&environment=production\n// &outcome=processed&limit=100` (newest first),\n// 2. days_since = now \u2212 the newest event's received_at,\n// 3. threshold = max(FLOOR_DAYS, ALERT_MULTIPLE \xD7 the MEDIAN gap between\n// those events) \u2014 your own cadence, not a number somebody picked,\n// 4. ok | stale | broken (or could_not_check when the read itself failed),\n// 5. compare with the stored row for that provider and post to Slack ONLY on\n// a crossing; a return to ok posts RESOLVED.\n//\n// Why the median and not the mean: one migration backfill or one Black Friday\n// inflates a mean for months. The median is what \"normal\" actually looks like.\n//\n// Why `?environment=production`: sandbox traffic is developer noise. A provider\n// can be chatty in test mode while production has been silent for a month \u2014\n// that is precisely the outage this function exists to catch.\n//\n// Run it by hand any time: `vxil functions invoke heartbeat`. It performs the\n// real check and returns the per-provider verdict as JSON; it still only posts\n// if a state genuinely crossed.\n\n// \u2500\u2500 Tune these. They are the whole policy. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\n/** Providers to probe. Must be names the payments API accepts. Drop the ones\n * you do not use \u2014 a provider that has never sent an event is skipped anyway. */\nconst PROVIDERS = ['stripe', 'paddle', 'paypal', 'revenuecat'] as const;\n/** Alert once silence exceeds this multiple of your median inter-event gap. */\nconst ALERT_MULTIPLE = 3;\n/** \u2026but never sooner than this many days, however chatty your integration is.\n * A low-volume app can legitimately go a week without a single lifecycle event. */\nconst FLOOR_DAYS = 7;\n/** Past this multiple of the threshold, `stale` becomes `broken`. This split is\n * the blueprint's own choice \u2014 the SLO defines the alert threshold, not the\n * severity ladder \u2014 so move it wherever your escalation wants it. */\nconst BROKEN_MULTIPLE = 2;\n/** Gaps needed before a median means anything. Below this the floor is used. */\nconst MIN_GAPS_FOR_MEDIAN = 5;\n/** Events sampled per provider (the API caps a page at 100). */\nconst SAMPLE = 100;\n\ntype State = 'ok' | 'stale' | 'broken' | 'could_not_check';\n\ntype Envelope = CronFunctionEnvelope;\ninterface WebhookEvent { event_id: string; provider: string; received_at: string | null }\ninterface StateData {\n provider: string; state?: State; detail?: string;\n days_since?: number; threshold_days?: number;\n last_event_at?: string; checked_at?: string; last_event_id?: string;\n}\ninterface Item { item_id: string; version?: number; data: StateData }\n\ninterface Verdict {\n provider: string;\n state: State;\n detail: string;\n days_since: number | null;\n threshold_days: number | null;\n last_event_at: string | null;\n last_event_id: string | null;\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Envelope;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const payments = env.scoped_jwts?.payments;\n const cms = env.scoped_jwts?.cms;\n const hook = env.secrets?.slack_webhook_url;\n if (!payments || !cms) return Response.json({ error: 'missing payments:read / cms scope' }, { status: 403 });\n if (!hook) return Response.json({ error: 'missing secret slack_webhook_url' }, { status: 409 });\n\n const store = new Store(base, cms);\n const checked: Verdict[] = [];\n const crossings: string[] = [];\n\n for (const provider of PROVIDERS) {\n const verdict = await check(base, payments, provider);\n if (!verdict) continue; // never sent an event \u2192 not part of this integration\n checked.push(verdict);\n const line = await reconcile(store, verdict);\n if (line) crossings.push(line);\n }\n\n let posted = 0;\n for (const text of crossings) {\n const r = await post(hook, text);\n if (r.ok) posted++;\n }\n return Response.json({ checked, crossings: crossings.length, posted });\n },\n};\n\n/** Measure one provider. `null` = the provider has never delivered a processed\n * production event, so there is no cadence to be silent against \u2014 the SLO's\n * \"once the tenant has ever received one\" precondition. */\nasync function check(base: string, jwt: string, provider: string): Promise<Verdict | null> {\n const url = `${base}/v1/payments/webhook-events`\n + `?provider=${encodeURIComponent(provider)}&environment=production&outcome=processed&limit=${SAMPLE}`;\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } }).catch(() => null);\n if (!res) {\n return verdict(provider, 'could_not_check', `could not reach the payments event log for ${provider}`);\n }\n if (!res.ok) {\n // 501 capability_not_enabled / 403 missing scope are configuration, not silence.\n return verdict(provider, 'could_not_check', `payments event log returned ${res.status} for ${provider}`);\n }\n const body = (await res.json().catch(() => ({}))) as { data?: { events?: WebhookEvent[] } };\n const events = (body.data?.events ?? []).filter((e) => e.received_at);\n if (events.length === 0) return null;\n\n // The API orders by received_at DESC, so [0] is the newest.\n const times = events.map((e) => Date.parse(e.received_at!)).filter(Number.isFinite).sort((a, b) => b - a);\n if (times.length === 0) return null;\n const last = times[0]!;\n const daysSince = round2((Date.now() - last) / 86_400_000);\n\n const gaps: number[] = [];\n for (let i = 0; i + 1 < times.length; i++) gaps.push((times[i]! - times[i + 1]!) / 86_400_000);\n const medianGap = gaps.length >= MIN_GAPS_FOR_MEDIAN ? median(gaps) : null;\n const threshold = round2(Math.max(FLOOR_DAYS, medianGap === null ? 0 : ALERT_MULTIPLE * medianGap));\n\n const state: State = daysSince <= threshold ? 'ok'\n : daysSince <= threshold * BROKEN_MULTIPLE ? 'stale'\n : 'broken';\n\n const cadence = medianGap === null\n ? `only ${gaps.length} gap(s) sampled \u2014 using the ${FLOOR_DAYS}-day floor`\n : `median gap ${round2(medianGap)}d \xD7 ${ALERT_MULTIPLE}, floor ${FLOOR_DAYS}d`;\n const detail = state === 'ok'\n ? `${provider}: last processed event ${daysSince}d ago (threshold ${threshold}d \u2014 ${cadence})`\n : `${provider} has sent no processed production event for ${daysSince}d \u2014 expected one within ${threshold}d (${cadence})`;\n\n return {\n provider, state, detail,\n days_since: daysSince,\n threshold_days: threshold,\n last_event_at: new Date(last).toISOString(),\n last_event_id: events[0]!.event_id ?? null,\n };\n}\n\n/** Persist the verdict; return a message ONLY when the state crossed. */\nasync function reconcile(store: Store, v: Verdict): Promise<string | null> {\n const now = new Date().toISOString();\n const data: StateData = {\n provider: v.provider, state: v.state, detail: v.detail, checked_at: now,\n ...(v.days_since !== null ? { days_since: v.days_since } : {}),\n ...(v.threshold_days !== null ? { threshold_days: v.threshold_days } : {}),\n ...(v.last_event_at ? { last_event_at: v.last_event_at } : {}),\n ...(v.last_event_id ? { last_event_id: v.last_event_id } : {}),\n };\n\n const current = await store.byProvider(v.provider);\n if (!current) {\n // First run. A healthy first sighting is remembered silently; an unhealthy\n // one is worth saying out loud immediately \u2014 you were already in the outage.\n const created = await store.create(data);\n return created && v.state !== 'ok' ? format(v) : null; // 409 \u21D2 a racing tick owns it\n }\n\n const prev = current.data.state ?? 'ok';\n if (prev === v.state) {\n // No crossing: refresh the measurement, stay quiet. A permanently-broken\n // provider therefore costs exactly one message, not one per day.\n await store.patch(current.item_id, current.version, data);\n return null;\n }\n // A CROSSING. If-Match makes exactly one of two overlapping ticks the winner.\n const ok = await store.patch(current.item_id, current.version, data);\n if (!ok) return null;\n return v.state === 'ok'\n ? `\u{1F7E2} RESOLVED: ${v.provider} is delivering again \u2014 last processed event ${v.days_since}d ago (was ${prev})`\n : format(v);\n}\n\nfunction format(v: Verdict): string {\n const dot = v.state === 'broken' ? '\u{1F534}' : v.state === 'stale' ? '\u{1F7E0}' : '\u{1F535}';\n const label = v.state === 'could_not_check' ? 'CHECK FAILED' : v.state.toUpperCase();\n return `${dot} [${label}] payments heartbeat \u2014 ${v.detail}`;\n}\n\n// \u2500\u2500 the cms state store (the REST envelope: { data: { items: [{item_id, version, data}] } }) \u2500\u2500\nclass Store {\n constructor(private base: string, private jwt: string) {}\n private h() { return { authorization: `Bearer ${this.jwt}`, 'content-type': 'application/json' }; }\n async byProvider(provider: string): Promise<Item | null> {\n const filter = encodeURIComponent(JSON.stringify({ provider }));\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state?filter=${filter}&limit=1`, { headers: this.h() });\n if (!res.ok) return null;\n const body = (await res.json()) as { data?: { items?: Item[] } };\n return body.data?.items?.[0] ?? null;\n }\n /** false on 409 \u2014 `provider` is unique, so a concurrent tick already claimed it. */\n async create(data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state`, {\n method: 'POST', headers: this.h(), body: JSON.stringify({ status: 'published', data }),\n });\n return res.ok;\n }\n /** false on 409 version_conflict \u2014 another tick transitioned this provider first. */\n async patch(itemId: string, version: number | undefined, data: StateData): Promise<boolean> {\n const res = await fetch(`${this.base}/v1/cms/items/heartbeat_state/${itemId}`, {\n method: 'PATCH',\n headers: { ...this.h(), ...(version !== undefined ? { 'if-match': String(version) } : {}) },\n body: JSON.stringify({ data }),\n });\n return res.ok;\n }\n}\n\n// \u2500\u2500 the chat webhook: `text` is Slack's field, `content` is Discord's \u2014 send both,\n// 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.\n// (Duplicated from templates/alerts-to-slack on purpose: a blueprint is a\n// self-contained clone, and every file under functions/ must be a declared\n// entry point, so there is no place for a shared module to live.)\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\nfunction verdict(provider: string, state: State, detail: string): Verdict {\n return { provider, state, detail, days_since: null, threshold_days: null, last_event_at: null, last_event_id: null };\n}\nfunction median(xs: number[]): number {\n const s = [...xs].sort((a, b) => a - b);\n const mid = s.length >> 1;\n return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2;\n}\nconst round2 = (n: number) => Math.round(n * 100) / 100;\n"
16141
16973
  }
16142
16974
  },
16143
16975
  {
@@ -17673,6 +18505,29 @@ function accountDashBase() {
17673
18505
  const creds = loadCredentials();
17674
18506
  return creds.session && creds.session_host ? dashboardOrigin(creds.session_host) : dashboardBase(DEFAULT_BASE);
17675
18507
  }
18508
+ function boundProjectSession() {
18509
+ const t = resolveTargetOrFail(selector());
18510
+ if (!t.tenantSlug) fail("no linked project \u2014 run `vxil link <slug>` (or `vxil quickstart`) first; this verb acts on the bound project");
18511
+ const slug = t.tenantSlug;
18512
+ const dashBase = dashboardBase(t.baseUrl);
18513
+ const dash = makeDash(dashBase);
18514
+ const run = (fn) => withDashSession(dash, dashBase, async (cookie) => {
18515
+ let id = t.tenantId && t.tenantId !== "(unknown)" ? t.tenantId : void 0;
18516
+ if (!id) {
18517
+ const f = await findTenantViaSession(dash, cookie, slug);
18518
+ if ("unauthorized" in f) return f;
18519
+ id = f.tenant.id;
18520
+ }
18521
+ return fn(cookie, id);
18522
+ });
18523
+ return { t, slug, dash, dashBase, run };
18524
+ }
18525
+ async function confirmTyped(what, question, expected) {
18526
+ if (hasFlag("yes")) return;
18527
+ if (!process.stdin.isTTY) fail(`${what} \u2014 pass --yes to confirm in a non-interactive run`);
18528
+ const ans = await prompt(question);
18529
+ if (ans !== expected) fail("confirmation did not match \u2014 aborted.");
18530
+ }
17676
18531
  function requireApi(sel = "prod", opts = {}) {
17677
18532
  const t = resolveTargetOrFail(sel, opts.exitCode ?? 1);
17678
18533
  if (!t.apiKey) fail(noKeyMessage(t), opts.exitCode ?? 1);
@@ -18273,30 +19128,7 @@ async function runGen(sel = "prod", opts = {}) {
18273
19128
  let env;
18274
19129
  let apiVersions;
18275
19130
  if (offline) {
18276
- const cfg = await loadVxilConfig();
18277
- env = cfg.env;
18278
- features = Object.keys(cfg.features);
18279
- collections = Object.entries(cfg.cms?.collections ?? {}).map(([collection, def]) => ({
18280
- collection,
18281
- ...def.singular ? { singular: def.singular } : {},
18282
- fields: Object.entries(def.fields).map(([field, f]) => ({
18283
- field,
18284
- type: f.type,
18285
- required: !!f.required,
18286
- index_slot: f.indexSlot ?? null,
18287
- ...f.relationTo ? { relation_to: f.relationTo } : {},
18288
- ...f.computed ? { computed: f.computed } : {},
18289
- // `validation.enum` becomes a string-literal union in the generated
18290
- // types, so the OFFLINE collector must carry it too (the online path
18291
- // gets it from GET /v1/cms/collections, which returns the whole row) —
18292
- // else `vxil gen --offline` and `vxil gen` would disagree.
18293
- ...f.validation ? { validation: f.validation } : {}
18294
- }))
18295
- }));
18296
- functions = Object.entries(cfg.functions ?? {}).map(([name, def]) => ({
18297
- name,
18298
- ...def.signature ? { signature: def.signature } : {}
18299
- }));
19131
+ ({ env, features, collections, functions } = genInputFromConfig(await loadVxilConfig()));
18300
19132
  } else {
18301
19133
  const { api, slug } = requireApi(sel);
18302
19134
  tenant = slug;
@@ -18327,10 +19159,6 @@ async function runGen(sel = "prod", opts = {}) {
18327
19159
  const catalog = emitMcp ? buildMcpCatalog(genInput) : void 0;
18328
19160
  const mcpPath = resolve7(process.cwd(), mcpOut);
18329
19161
  const outPath = resolve7(process.cwd(), out);
18330
- const body = (s) => {
18331
- const i = s.indexOf("export interface VxilSchema");
18332
- return i >= 0 ? s.slice(i) : s;
18333
- };
18334
19162
  const counts = {
18335
19163
  features: features.length,
18336
19164
  collections: collections.length,
@@ -18339,25 +19167,15 @@ async function runGen(sel = "prod", opts = {}) {
18339
19167
  };
18340
19168
  if (hasFlag("check")) {
18341
19169
  const existing = existsSync8(outPath) ? readFileSync8(outPath, "utf8") : "";
18342
- if (body(existing) !== body(src)) {
19170
+ const types2 = checkGeneratedTypes(existing, src, { offline });
19171
+ if (types2.unpinnedNotice) {
19172
+ console.error(`vxil: ${out} was generated --offline, so the API majors are not pinned in it \u2014 run \`vxil gen\` online once to pin them`);
19173
+ }
19174
+ if (!types2.upToDate) {
18343
19175
  fail(`${out} is out of date (drift) \u2014 run \`vxil gen\` and commit. A collection/feature/function changed since it was generated.`);
18344
19176
  }
18345
- if (catalog) {
18346
- let upToDate = false;
18347
- if (existsSync8(mcpPath)) {
18348
- try {
18349
- const cur = JSON.parse(readFileSync8(mcpPath, "utf8"));
18350
- const want = JSON.parse(JSON.stringify(catalog));
18351
- delete cur.generated_at;
18352
- delete want.generated_at;
18353
- upToDate = JSON.stringify(cur) === JSON.stringify(want);
18354
- } catch {
18355
- upToDate = false;
18356
- }
18357
- }
18358
- if (!upToDate) {
18359
- fail(`${mcpOut} is out of date (drift) \u2014 run \`vxil gen\` and commit. A collection/feature/function changed since it was generated.`);
18360
- }
19177
+ if (catalog && !mcpCatalogUpToDate(existsSync8(mcpPath) ? readFileSync8(mcpPath, "utf8") : null, catalog)) {
19178
+ fail(`${mcpOut} is out of date (drift) \u2014 run \`vxil gen\` and commit. A collection/feature/function changed since it was generated.`);
18361
19179
  }
18362
19180
  if (jsonOut) console.log(JSON.stringify({ ok: true, check: "up-to-date", ...counts }));
18363
19181
  else console.log(`vxil gen --check: ${out}${catalog ? ` + ${mcpOut}` : ""} is up to date.`);
@@ -19059,10 +19877,28 @@ try {
19059
19877
  }
19060
19878
  case "invites": {
19061
19879
  const [sub = "list"] = positional(0);
19062
- const INVITES_USAGE = "usage: vxil invites [list] [--json] \xB7 vxil invites generate [--count <n>] [--json]";
19063
- if (sub !== "list" && sub !== "generate") fail(INVITES_USAGE);
19880
+ const INVITES_USAGE = "usage: vxil invites [list] [--json] \xB7 vxil invites generate [--count <n>] [--json] \xB7 vxil invites accept <token | invite-url> [--json]";
19881
+ if (sub !== "list" && sub !== "generate" && sub !== "accept") fail(INVITES_USAGE);
19064
19882
  const base2 = accountDashBase();
19065
19883
  const dash = makeDash(base2);
19884
+ if (sub === "accept") {
19885
+ const tok = parseInviteToken(positional(1)[0]);
19886
+ if ("error" in tok) fail(tok.error);
19887
+ const look = await lookupInvite(dash, tok.token);
19888
+ if ("refused" in look) fail(`this invitation cannot be accepted (${look.refused})`);
19889
+ console.error(`vxil invites accept \u2192 ${dashboardOrigin(base2)} \u2014 ${look.preview.role} on '${look.preview.tenant_display_name}' (expires ${look.preview.expires_at})`);
19890
+ const r = await withDashSession(dash, base2, (c) => acceptInviteViaSession(dash, c, tok.token));
19891
+ let slug;
19892
+ try {
19893
+ const l = await withDashSession(dash, base2, (c) => listProjectsViaSession(dash, c));
19894
+ slug = l.tenants.find((x) => x.id === r.tenant_id)?.slug;
19895
+ } catch {
19896
+ }
19897
+ if (jsonOut) console.log(JSON.stringify({ ...r, slug: slug ?? null, project: look.preview.tenant_display_name }));
19898
+ else console.log(`\u2713 you are now ${r.role} on '${slug ?? look.preview.tenant_display_name}'`);
19899
+ if (slug) console.error(`next: vxil link ${slug}`);
19900
+ break;
19901
+ }
19066
19902
  if (sub === "list") {
19067
19903
  const l = await withDashSession(dash, base2, (c) => listInvitesViaSession(dash, c));
19068
19904
  if (jsonOut) console.log(JSON.stringify(l, null, 2));
@@ -19080,6 +19916,264 @@ try {
19080
19916
  }
19081
19917
  break;
19082
19918
  }
19919
+ case "members": {
19920
+ const [sub = "list", arg] = positional(0);
19921
+ const MEMBERS_USAGE = "usage: vxil members [list] [--json] \xB7 members invite <email> --role viewer|developer|admin|owner \xB7 members add <email> --role <r> \xB7 members rm <user_id|email> [--yes] \xB7 members uninvite <email|invite_id> [--yes]";
19922
+ if (sub === "help" || hasFlag("help")) {
19923
+ console.log(`${MEMBERS_USAGE}
19924
+ list members with their roles, then the pending e-mail invitations (expired ones badged)
19925
+ invite invite by e-mail (admin+; owner for the owner role): an address that already has an account is a
19926
+ member at once, any other gets a 7-day e-mail \u2014 the server never says which; re-inviting resends (60 s apart)
19927
+ add the strict form: the address MUST already have an account (no mail); an upsert, so it is also
19928
+ how you CHANGE an existing member's role
19929
+ rm remove a member (admin+; owner to remove an owner; the last owner is refused); you may remove yourself
19930
+ uninvite revoke a pending invitation by e-mail or invite id
19931
+ The project is the bound slot (--dev / --target <name> select another); the session is \`vxil login\`'s.`);
19932
+ break;
19933
+ }
19934
+ if (!["list", "invite", "add", "rm", "uninvite"].includes(sub)) fail(MEMBERS_USAGE);
19935
+ const who = sub === "invite" || sub === "add" ? (() => {
19936
+ const e = parseMemberEmail(arg);
19937
+ if ("error" in e) fail(e.error);
19938
+ const r2 = parseMemberRole(flag("role"));
19939
+ if ("error" in r2) fail(r2.error);
19940
+ return { email: e.email, role: r2.role };
19941
+ })() : void 0;
19942
+ if ((sub === "rm" || sub === "uninvite") && !arg) fail(MEMBERS_USAGE);
19943
+ const { t, slug, dash, dashBase, run } = boundProjectSession();
19944
+ if (sub === "list") {
19945
+ const { members, invites: invites2 } = await run((c, id) => listMembersViaSession(dash, c, id));
19946
+ if (jsonOut) console.log(JSON.stringify({ project: slug, members, invites: invites2 }, null, 2));
19947
+ else for (const l of renderMembers(members, invites2)) console.log(l);
19948
+ break;
19949
+ }
19950
+ if (sub === "invite") {
19951
+ announceTarget(t, `members invite ${who.email}`);
19952
+ const r2 = await run((c, id) => inviteMemberViaSession(dash, c, id, who));
19953
+ if (jsonOut) console.log(JSON.stringify({ ...r2, invited: true }));
19954
+ else console.log(`\u2713 invited ${r2.email} as ${r2.role} \u2014 an existing account is a member now, any other address got an e-mail (valid 7 days); \`vxil members list\` shows which`);
19955
+ break;
19956
+ }
19957
+ if (sub === "add") {
19958
+ announceTarget(t, `members add ${who.email}`);
19959
+ const r2 = await run((c, id) => addMemberViaSession(dash, c, id, who));
19960
+ if (jsonOut) console.log(JSON.stringify(r2));
19961
+ else console.log(`\u2713 ${who.email} is ${r2.role} on '${slug}' (${r2.user_id})`);
19962
+ break;
19963
+ }
19964
+ if (sub === "rm") {
19965
+ const { members } = await run((c, id) => listMembersViaSession(dash, c, id));
19966
+ const row2 = findMember(members, arg);
19967
+ if (!row2) fail(`no member '${arg}' on '${slug}' \u2014 \`vxil members list\` shows the ids and e-mails`);
19968
+ announceTarget(t, `members rm ${row2.email}`);
19969
+ const { account } = await withDashSession(dash, dashBase, (c) => showAccountViaSession(dash, c));
19970
+ const self = account.id === row2.user_id;
19971
+ if (self) console.error(`vxil: warning \u2014 that is YOU (${account.email}); you lose access to '${slug}' and every key this machine holds for it stops being yours to manage`);
19972
+ await confirmTyped("members rm removes their access", `remove ${row2.email} (${row2.role}) from '${slug}'? type the e-mail to confirm: `, row2.email);
19973
+ await run((c, id) => removeMemberViaSession(dash, c, id, row2.user_id));
19974
+ if (jsonOut) console.log(JSON.stringify({ user_id: row2.user_id, email: row2.email, removed: true, self }));
19975
+ else console.log(`\u2713 removed ${row2.email} from '${slug}'`);
19976
+ break;
19977
+ }
19978
+ const { invites } = await run((c, id) => listMembersViaSession(dash, c, id));
19979
+ const inv = findPendingInvite(invites, arg);
19980
+ if (!inv) fail(`no pending invitation '${arg}' on '${slug}' \u2014 \`vxil members list\` shows them`);
19981
+ announceTarget(t, `members uninvite ${inv.email}`);
19982
+ await confirmTyped("members uninvite revokes the e-mailed link", `revoke the invitation for ${inv.email} (${inv.role})? type the e-mail to confirm: `, inv.email);
19983
+ const r = await run((c, id) => revokeInviteViaSession(dash, c, id, inv.id));
19984
+ if (jsonOut) console.log(JSON.stringify({ invite_id: r.revoked, email: inv.email, revoked: true }));
19985
+ else console.log(`\u2713 revoked the invitation for ${inv.email} \u2014 its link no longer works`);
19986
+ break;
19987
+ }
19988
+ case "projects": {
19989
+ const [sub = "list", arg] = positional(0);
19990
+ const PROJECTS_USAGE = "usage: vxil projects [list] [--json] \xB7 projects create <slug> [--name <display>] [--json] \xB7 projects rm <slug> [--yes] [--json]";
19991
+ if (!["list", "create", "rm"].includes(sub)) fail(PROJECTS_USAGE);
19992
+ const base2 = accountDashBase();
19993
+ const dash = makeDash(base2);
19994
+ const host = dashboardOrigin(base2);
19995
+ if (sub === "list") {
19996
+ const { tenants: tenants2 } = await withDashSession(dash, base2, (c) => listProjectsViaSession(dash, c));
19997
+ const bound = new Map(bindingSlots(loadProject()).map((s) => [s.slug, s.label]));
19998
+ if (jsonOut) console.log(JSON.stringify({ dashboard: host, tenants: tenants2.map((x) => ({ ...x, bound: bound.get(x.slug) ?? null })) }, null, 2));
19999
+ else for (const l of renderProjects(tenants2, bound)) console.log(l);
20000
+ break;
20001
+ }
20002
+ if (sub === "create") {
20003
+ const p = parseProjectCreate(arg, flag("name"));
20004
+ if ("error" in p) fail(p.error);
20005
+ console.error(`vxil projects create ${p.body.slug} \u2192 ${host}`);
20006
+ const { project } = await withDashSession(dash, base2, (c) => createProjectViaSession(dash, c, p.body));
20007
+ if (jsonOut) console.log(JSON.stringify(project, null, 2));
20008
+ else console.log(`\u2713 created '${project.slug}' (${project.display_name}, ${project.tier}) \u2014 no key and no features yet`);
20009
+ console.error(`next: vxil link ${project.slug} --mint && vxil push (this repo's binding is unchanged)`);
20010
+ break;
20011
+ }
20012
+ if (!arg) fail(PROJECTS_USAGE);
20013
+ const { tenants } = await withDashSession(dash, base2, (c) => listProjectsViaSession(dash, c));
20014
+ const victim = tenants.find((x) => x.slug === arg);
20015
+ if (!victim) fail(`no project '${arg}' on this account \u2014 \`vxil projects list\``);
20016
+ const proj = loadProject();
20017
+ if (proj?.slug === arg && !hasFlag("yes")) {
20018
+ fail(`'${arg}' is this repo's PRIMARY binding \u2014 pass --yes to delete it anyway (the binding file stays; \`vxil link <slug>\` re-binds afterwards)`);
20019
+ }
20020
+ console.error(`vxil projects rm ${arg} \u2192 ${host} (${victim.display_name}, ${victim.tier}${victim.role ? `, you are ${victim.role}` : ""})`);
20021
+ await confirmTyped("projects rm is irreversible", `delete project '${arg}' and ALL of its data permanently? type the slug to confirm: `, arg);
20022
+ const { report } = await withDashSession(dash, base2, (c) => deleteProjectViaSession(dash, c, victim.id, arg));
20023
+ const cleanup = cleanupAfterDelete(loadCredentials(), proj, { tenant_id: victim.id, slug: arg });
20024
+ if (cleanup.credsChanged) saveCredentials(cleanup.creds);
20025
+ if (cleanup.projChanged && cleanup.proj) saveProject(cleanup.proj);
20026
+ if (jsonOut) {
20027
+ console.log(JSON.stringify({ slug: arg, tenant_id: victim.id, deleted: true, report, cleared_locally: cleanup.cleared, primary_binding_stale: cleanup.primaryWasIt }, null, 2));
20028
+ } else {
20029
+ console.log(`\u2713 deleted '${arg}'`);
20030
+ for (const [k, v] of Object.entries(report)) console.log(` ${k}: ${v !== null && typeof v === "object" ? JSON.stringify(v) : String(v)}`);
20031
+ }
20032
+ if (cleanup.cleared.length) console.error(`cleared locally: ${cleanup.cleared.join(", ")}`);
20033
+ if (cleanup.primaryWasIt) console.error(`this repo's primary binding (.vxil/project.json) still names '${arg}' \u2014 run \`vxil link <slug>\` to bind another project`);
20034
+ break;
20035
+ }
20036
+ case "billing": {
20037
+ const [sub = "status"] = positional(0);
20038
+ const BILLING_USAGE = "usage: vxil billing [status] [--json] \xB7 vxil billing upgrade --tier <free|developer|team|business> [--yes] [--json] (upgrade also downgrades: --tier free)";
20039
+ if (sub !== "status" && sub !== "upgrade") fail(BILLING_USAGE);
20040
+ const tier = sub === "upgrade" ? (flag("tier") ?? "").trim() : "";
20041
+ if (sub === "upgrade" && !tier) fail(BILLING_USAGE);
20042
+ const { t, slug, dash, run } = boundProjectSession();
20043
+ if (sub === "status") {
20044
+ const { billing } = await run((c, id) => getBillingViaSession(dash, c, id));
20045
+ if (jsonOut) console.log(JSON.stringify({ project: slug, ...billing }, null, 2));
20046
+ else for (const l of renderBilling(billing)) console.log(l);
20047
+ break;
20048
+ }
20049
+ announceTarget(t, `billing upgrade --tier ${tier}`);
20050
+ if (tier === "free") {
20051
+ await confirmTyped("moving to Free ends the paid plan", `move '${slug}' to the Free plan (at the end of the current period)? type the slug to confirm: `, slug);
20052
+ }
20053
+ const { outcome, raw } = await run((c, id) => checkoutViaSession(dash, c, id, tier));
20054
+ const view = renderCheckout(outcome);
20055
+ if (jsonOut) console.log(JSON.stringify({ ...raw, outcome: outcome.kind }, null, 2));
20056
+ else console.log(view.stdout);
20057
+ for (const l of view.stderr) console.error(l);
20058
+ break;
20059
+ }
20060
+ case "files": {
20061
+ const [sub, arg] = positional(0);
20062
+ const FILES_USAGE = "usage: vxil files put <path> --user <user_id> [--content-type <type>] [--json] \xB7 vxil files rm <object_id> [--yes] [--json]";
20063
+ if (sub === "put") {
20064
+ if (!arg) fail(FILES_USAGE);
20065
+ const user = flag("user");
20066
+ if (!user) fail("--user <user_id> is required \u2014 the end user who owns the object");
20067
+ const path = resolve7(process.cwd(), arg);
20068
+ let bytes;
20069
+ try {
20070
+ bytes = readFileSync8(path);
20071
+ } catch {
20072
+ fail(`cannot read ${arg}`);
20073
+ }
20074
+ const filename = basename4(path);
20075
+ const contentType = contentTypeFor(filename, flag("content-type"));
20076
+ const { api } = requireApi(selector(), { write: `files put ${filename}` });
20077
+ const put = async (url, method, b2, ct) => {
20078
+ const res = await fetch(url, { method, headers: { "content-type": ct }, body: new Blob([b2]) });
20079
+ return { status: res.status, ...res.ok ? {} : { text: (await res.text()).slice(0, 200) } };
20080
+ };
20081
+ const r = await filesPut(api, put, { user_id: user, filename, content_type: contentType, bytes: new Uint8Array(bytes) });
20082
+ if (jsonOut) console.log(JSON.stringify(r));
20083
+ else console.log(r.object_id);
20084
+ console.error(`\u2713 uploaded ${filename} (${bytes.byteLength} bytes, ${contentType}) for ${user} \u2014 object ${r.object_id}, status ${r.status}`);
20085
+ break;
20086
+ }
20087
+ if (sub === "rm") {
20088
+ if (!arg) fail(FILES_USAGE);
20089
+ const { api } = requireApi(selector(), { write: `files rm ${arg}` });
20090
+ await confirmTyped("files rm is irreversible", `delete object ${arg}? type the object id to confirm: `, arg);
20091
+ const r = await filesRm(api, arg);
20092
+ if (jsonOut) console.log(JSON.stringify(r));
20093
+ else console.log(`\u2713 deleted ${arg}`);
20094
+ break;
20095
+ }
20096
+ fail(FILES_USAGE);
20097
+ break;
20098
+ }
20099
+ case "webhooks": {
20100
+ const [sub, verb, arg] = positional(0);
20101
+ const WEBHOOKS_USAGE = "usage: vxil webhooks sources add --provider <stripe|paddle|github|slack|revenuecat|generic> --name <n> [--forward-url <https>] \xB7 sources list \xB7 sources rm <source_id> [--yes] \xB7 sources test <source_id> \xB7 vxil webhooks events replay <event_id> \xB7 events runs <event_id>";
20102
+ if (sub === "sources") {
20103
+ if (verb === "add") {
20104
+ const p = parseSourceAdd({ provider: flag("provider"), name: flag("name"), forwardUrl: flag("forward-url") });
20105
+ if ("error" in p) fail(p.error);
20106
+ const { api, baseUrl } = requireApi(selector(), { write: `webhooks sources add ${p.body.name}` });
20107
+ const s = await sourcesAdd(api, p.body);
20108
+ console.log(sourceAddStdout(s, baseUrl, jsonOut));
20109
+ for (const l of sourceAddNotices(s)) console.error(l);
20110
+ break;
20111
+ }
20112
+ if (verb === "list") {
20113
+ const { api } = requireApi(selector());
20114
+ const rows = await sourcesList(api);
20115
+ if (jsonOut) console.log(JSON.stringify({ sources: rows }, null, 2));
20116
+ else if (!rows.length) console.log("(no inbound sources \u2014 `vxil webhooks sources add --provider \u2026 --name \u2026`)");
20117
+ else for (const r of rows) console.log(`${r.source_id} ${String(r.provider).padEnd(10)} ${r.name}`);
20118
+ break;
20119
+ }
20120
+ if (verb === "rm") {
20121
+ if (!arg) fail(WEBHOOKS_USAGE);
20122
+ const { api } = requireApi(selector(), { write: `webhooks sources rm ${arg}` });
20123
+ await confirmTyped("webhooks sources rm stops the receiver URL", `delete source ${arg}? type the source id to confirm: `, arg);
20124
+ const r = await sourcesRm(api, arg);
20125
+ if (jsonOut) console.log(JSON.stringify(r));
20126
+ else console.log(`\u2713 deleted source ${arg} \u2014 its receiver URL answers 404 from now on`);
20127
+ break;
20128
+ }
20129
+ if (verb === "test") {
20130
+ if (!arg) fail(WEBHOOKS_USAGE);
20131
+ const { api } = requireApi(selector(), { write: `webhooks sources test ${arg}` });
20132
+ const r = await sourcesTest(api, arg);
20133
+ if (jsonOut) console.log(JSON.stringify(r, null, 2));
20134
+ else console.log(r.pending ? `\u2026 test event queued (run ${String(r.run_id ?? "?")}) \u2014 \`vxil api GET /v1/jobs/runs/${String(r.run_id ?? "<run_id>")}\` for the outcome` : `${r.delivered ? "\u2713 delivered" : "\u2717 not delivered"} (${String(r.state ?? "?")}${r.last_error_class ? ` \u2014 ${String(r.last_error_class)}: ${String(r.last_error_msg ?? "")}` : ""})`);
20135
+ break;
20136
+ }
20137
+ fail(WEBHOOKS_USAGE);
20138
+ }
20139
+ if (sub === "events") {
20140
+ if (!arg) fail(WEBHOOKS_USAGE);
20141
+ if (verb === "replay") {
20142
+ const { api } = requireApi(selector(), { write: `webhooks events replay ${arg}` });
20143
+ const r = await eventsReplay(api, arg);
20144
+ if (jsonOut) console.log(JSON.stringify(r, null, 2));
20145
+ else console.log(`\u2713 replay queued for ${arg} \u2014 \`vxil webhooks events runs ${arg}\` shows the deliveries`);
20146
+ break;
20147
+ }
20148
+ if (verb === "runs") {
20149
+ const { api } = requireApi(selector());
20150
+ console.log(JSON.stringify(await eventsRuns(api, arg), null, 2));
20151
+ break;
20152
+ }
20153
+ }
20154
+ fail(WEBHOOKS_USAGE);
20155
+ break;
20156
+ }
20157
+ case "search": {
20158
+ const [sub, verb, key] = positional(0);
20159
+ const SEARCH_USAGE = "usage: vxil search sync run <sourceKey> [--sweep] [--json] \xB7 vxil search sync reset <sourceKey> [--purge] [--yes] [--json]";
20160
+ if (sub !== "sync" || !key || verb !== "run" && verb !== "reset") fail(SEARCH_USAGE);
20161
+ if (verb === "run") {
20162
+ const sweep = hasFlag("sweep");
20163
+ const { api: api2 } = requireApi(selector(), { write: `search sync run ${key}${sweep ? " --sweep" : ""}` });
20164
+ const r2 = await searchSyncRun(api2, key, { sweep });
20165
+ if (jsonOut) console.log(JSON.stringify(r2, null, 2));
20166
+ else console.log(`\u2713 ${sweep ? "swept" : "synced"} ${key}: phase ${String(r2.phase ?? "?")} \xB7 processed ${String(r2.processed ?? 0)} \xB7 deleted ${String(r2.deleted ?? 0)} \xB7 unchanged ${String(r2.skipped_unchanged ?? 0)}`);
20167
+ break;
20168
+ }
20169
+ const purge = hasFlag("purge");
20170
+ const { api } = requireApi(selector(), { write: `search sync reset ${key}${purge ? " --purge" : ""}` });
20171
+ if (purge) await confirmTyped("search sync reset --purge hard-deletes every synced document", `reset '${key}' AND purge its documents? type the source key to confirm: `, key);
20172
+ const r = await searchSyncReset(api, key, { purge });
20173
+ if (jsonOut) console.log(JSON.stringify(r, null, 2));
20174
+ else console.log(`\u2713 reset ${key} to a fresh backfill (phase ${String(r.phase ?? "?")})${purge ? ` \u2014 purged ${String(r.purged ?? 0)} document(s)` : ""}`);
20175
+ break;
20176
+ }
19083
20177
  case "listen": {
19084
20178
  const rawUrl = flag("forward-to");
19085
20179
  if (!rawUrl) {
@@ -19451,9 +20545,9 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
19451
20545
  const qs = since ? `?since=${encodeURIComponent(since)}` : "";
19452
20546
  const res = await api("GET", `/v1/functions/${encodeURIComponent(name)}/logs${qs}`);
19453
20547
  if (res.body.error) printEnvelope(res);
19454
- const data4 = res.body.data;
19455
- if (data4.lines.length) console.log(formatLogLines(data4.lines));
19456
- if (data4.next_since) since = data4.next_since;
20548
+ const data2 = res.body.data;
20549
+ if (data2.lines.length) console.log(formatLogLines(data2.lines));
20550
+ if (data2.next_since) since = data2.next_since;
19457
20551
  };
19458
20552
  await fetchPage();
19459
20553
  if (hasFlag("tail")) {
@@ -19566,6 +20660,36 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
19566
20660
  }
19567
20661
  break;
19568
20662
  }
20663
+ if (sub === "bulk-rm") {
20664
+ const [collection] = positional(1);
20665
+ const BULK_USAGE = "usage: vxil cms bulk-rm <collection> --filter '<json>' [--limit <1..100>] [--dry-run] [--yes] [--json]";
20666
+ if (!collection) fail(BULK_USAGE);
20667
+ const filter = parseBulkFilter(flag("filter"));
20668
+ if ("error" in filter) fail(filter.error);
20669
+ const limit = parseBulkLimit(flag("limit"));
20670
+ if ("error" in limit) fail(limit.error);
20671
+ const { api: api2, target } = requireApi(selector(), { write: `cms bulk-rm ${collection}` });
20672
+ const dry = await bulkRmDryRun(api2, collection, filter.filter);
20673
+ const where = `'${collection}' on '${target.tenantSlug ?? "(env key)"}'`;
20674
+ console.error(`${dry.matched}${dry.truncated ? "+" : ""} item(s) in ${where} match ${JSON.stringify(filter.filter)}`);
20675
+ if (hasFlag("dry-run")) {
20676
+ if (jsonOut) console.log(JSON.stringify({ collection, filter: filter.filter, matched: dry.matched, truncated: dry.truncated, dry_run: true, deleted: 0 }));
20677
+ else console.log(`dry run \u2014 nothing deleted; drop --dry-run to soft-delete them (purged after 30 days)`);
20678
+ break;
20679
+ }
20680
+ if (dry.matched === 0) {
20681
+ if (jsonOut) console.log(JSON.stringify({ collection, filter: filter.filter, matched: 0, dry_run: false, deleted: 0 }));
20682
+ else console.log("nothing matches \u2014 nothing to delete");
20683
+ break;
20684
+ }
20685
+ await confirmTyped("cms bulk-rm deletes items", `soft-delete ${dry.matched}${dry.truncated ? "+" : ""} item(s) from ${where}? type the collection name to confirm: `, collection);
20686
+ const done = await bulkRmAll(api2, collection, filter.filter, limit.limit, (p, totals) => {
20687
+ if (p.deleted > 0) console.error(` \u2026 deleted ${totals.deleted}${p.cascaded ? ` (+${p.cascaded} cascaded)` : ""}`);
20688
+ });
20689
+ if (jsonOut) console.log(JSON.stringify({ collection, filter: filter.filter, matched: dry.matched, dry_run: false, ...done }));
20690
+ else console.log(`\u2713 soft-deleted ${done.deleted} item(s) from ${where}${done.cascaded ? ` (${done.cascaded} cascaded)` : ""}${done.set_null ? ` (${done.set_null} relation(s) set null)` : ""} \u2014 purged after 30 days${done.truncated ? "; stopped at the page cap, re-run to continue" : ""}`);
20691
+ break;
20692
+ }
19569
20693
  const { api } = requireApi(selector());
19570
20694
  if (sub === "status") {
19571
20695
  printEnvelope(await api("GET", "/v1/cms/collections"));
@@ -19586,7 +20710,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
19586
20710
  console.log(` \u26A0 ${done.skipped} row(s) were written while the re-index ran and were NOT re-projected \u2014 re-run \`vxil cms reindex ${collection}\` when writes are quiet`);
19587
20711
  }
19588
20712
  } else {
19589
- fail("usage: vxil cms status|pull|reindex <collection> [--field <field>]|drop <collection> [--yes]");
20713
+ fail("usage: vxil cms status|pull|reindex <collection> [--field <field>]|drop <collection> [--yes]|bulk-rm <collection> --filter '<json>' [--dry-run] [--yes]");
19590
20714
  }
19591
20715
  break;
19592
20716
  }
@@ -19630,6 +20754,26 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
19630
20754
  }
19631
20755
  case "users": {
19632
20756
  const [sub, id] = rest;
20757
+ if (sub === "import") {
20758
+ const [file] = positional(1);
20759
+ if (!file) fail("usage: vxil users import <users.json|users.csv> [--json] (csv: a header with id[,email[,display_name]]; RFC 4180 quotes for a comma inside a field)");
20760
+ let text = "";
20761
+ try {
20762
+ text = readFileSync8(resolve7(process.cwd(), file), "utf8");
20763
+ } catch {
20764
+ fail(`cannot read ${file}`);
20765
+ }
20766
+ const parsed = parseUsersFile(text, file);
20767
+ if ("error" in parsed) fail(parsed.error);
20768
+ const { api: api2 } = requireApi(selector(), { write: `users import ${file}` });
20769
+ console.error(`${parsed.users.length} user(s) in ${file}`);
20770
+ const r = await importUsers(api2, parsed.users, { onProgress: (d, total) => {
20771
+ if (total > 100) console.error(` \u2026 ${d}/${total}`);
20772
+ } });
20773
+ if (jsonOut) console.log(JSON.stringify({ file, users: parsed.users.length, ...r }));
20774
+ else console.log(`\u2713 upserted ${r.upserted} user(s) from ${file} in ${r.chunks} call(s)`);
20775
+ break;
20776
+ }
19633
20777
  const { api } = requireApi(selector(), sub === "add" ? { write: "users add" } : {});
19634
20778
  if (sub === "add") {
19635
20779
  if (!id) fail("usage: vxil users add <id> --email <email>");
@@ -19637,21 +20781,21 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
19637
20781
  } else if (sub === "list") {
19638
20782
  const email = flag("email");
19639
20783
  printEnvelope(await api("GET", `/v1/users${email ? `?email=${encodeURIComponent(email)}` : ""}`));
19640
- } else fail("usage: vxil users add|list");
20784
+ } else fail("usage: vxil users add|list|import <file>");
19641
20785
  break;
19642
20786
  }
19643
20787
  case "send": {
19644
20788
  const { api } = requireApi(selector(), { write: "send" });
19645
20789
  const [userId, template] = rest;
19646
20790
  if (!userId || !template) fail("usage: vxil send <user_id> <template> --data '<json>'");
19647
- let data4 = {};
20791
+ let data2 = {};
19648
20792
  const raw = flag("data");
19649
20793
  try {
19650
- data4 = raw ? JSON.parse(raw) : {};
20794
+ data2 = raw ? JSON.parse(raw) : {};
19651
20795
  } catch {
19652
20796
  fail("--data must be valid JSON");
19653
20797
  }
19654
- printEnvelope(await api("POST", "/v1/notifications/send", { user_id: userId, template, data: data4 }));
20798
+ printEnvelope(await api("POST", "/v1/notifications/send", { user_id: userId, template, data: data2 }));
19655
20799
  break;
19656
20800
  }
19657
20801
  case "deliveries": {
@@ -19666,6 +20810,10 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
19666
20810
  break;
19667
20811
  }
19668
20812
  case "api": {
20813
+ if (hasFlag("help") || positional(0)[0] === "help") {
20814
+ console.log(apiCheatSheet());
20815
+ break;
20816
+ }
19669
20817
  let parsed;
19670
20818
  try {
19671
20819
  parsed = parseApiArgs(positional(0), flag("data"));
@@ -19679,10 +20827,13 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
19679
20827
  }
19680
20828
  default:
19681
20829
  console.log(`vxil \u2014 a Supabase-grade code-first CLI for the whole platform
19682
- init [--template <id>] \xB7 templates \xB7 try (keyless sandbox) \xB7 quickstart [--dev --ttl <h>] \xB7 login \xB7 link <slug> [--key <k>] [--as dev|<name>] [--mint]
20830
+ init [--template <id>] \xB7 templates \xB7 try (keyless sandbox) \xB7 quickstart [--features a,b] [--invite <code>] [--env <label>] [--dev [--ttl <h>]] [--no-push] \xB7 login \xB7 link <slug> [--key <k>] [--as dev|<name>] [--env <label>] [--mint]
19683
20831
  keys mint --name <n> --scopes <a,b,c> [--env live|test] [--store] \xB7 keys list \xB7 keys revoke <key_id> [--yes] \u2014 a NARROW scoped key for a server (shown once; needs a vxil login session)
19684
20832
  keys perms <key_id> [--allow <a,b> | --clear-allow] [--deny <c> | --clear-deny] \u2014 a key's MCP tool permissions (admin+)
19685
- account [show] \xB7 account set [--locale <code>] [--display-name <n>] \xB7 account password \xB7 logout \xB7 invites [list] \xB7 invites generate [--count <n>]
20833
+ account [show] \xB7 account set [--locale <code>] [--display-name <n>] \xB7 account password \xB7 logout \xB7 invites [list] \xB7 invites generate [--count <n>] \xB7 invites accept <token|url>
20834
+ members [list] \xB7 members invite <email> --role <r> \xB7 members add <email> --role <r> \xB7 members rm <user_id|email> [--yes] \xB7 members uninvite <email|id> [--yes] \u2014 the bound project's team (session)
20835
+ projects [list] \xB7 projects create <slug> [--name <n>] \xB7 projects rm <slug> [--yes] \u2014 the account's projects (rm: owner-only, typed slug, irreversible)
20836
+ billing [status] \xB7 billing upgrade --tier <free|developer|team|business> [--yes] \u2014 prints the hosted checkout URL (never opens a browser); --tier free downgrades
19686
20837
  architect "<describe your app>" \u2014 plain English in, a reviewed vxil.config.ts draft out
19687
20838
  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>]
19688
20839
  push production overrides: --allow-mock-in-prod \xB7 --allow-sandbox-in-prod \xB7 --allow-dev-origins-in-prod \xB7 --yes
@@ -19696,11 +20847,13 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
19696
20847
  migrate payments --from-provider stripe|paddle|paypal|revenuecat (--customers <f.csv> | --all) [--dry-run] \u2014 backfill subscriptions via the server sync leg (resumable)
19697
20848
  payments simulate --scenario refund-pair|cross-platform-unlock|renewal|expiry|past-due-grace|transfer --user <id> [--tier <t>] \u2014 mock/dev projects only
19698
20849
  env pull [--dev] [--file <f>] [--print] [--no-gitignore]
19699
- functions new|deploy|list|delete|enable|disable [--yes]|invoke [--async]|logs [--tail]|dev <name> [--port <n>] \xB7 cms status|pull|reindex <collection>|drop <collection> [--yes] \xB7 dev up [--ttl <h>] [--new]|down [--yes]|seed|reset
20850
+ functions new|deploy|list|delete|enable|disable [--yes]|invoke [--async]|logs [--tail]|dev <name> [--port <n>] \xB7 cms status|pull|reindex <collection>|drop <collection> [--yes]|bulk-rm <collection> --filter '<json>' [--dry-run] [--yes] \xB7 dev up [--ttl <h>] [--new]|down [--yes]|seed|reset
20851
+ files put <path> --user <id> [--content-type <t>] \xB7 files rm <object_id> [--yes] \xB7 webhooks sources add|list|rm|test \xB7 webhooks events replay|runs <event_id> \xB7 search sync run|reset <sourceKey> [--sweep|--purge] \xB7 users import <file.json|.csv>
20852
+ api --help \u2014 the operator cheat-sheet: every dashboard action as one 'vxil api' line, grouped by feature
19700
20853
  listen --forward-to <url> [--dev] [--source <id>] [--events <prefix,\u2026>] [--replay-last <n>] [--interval <s>] [--show-runs] [--raw] \u2014 forward inbound webhook events to a local server
19701
20854
  mcp install [--client cursor|claude|vscode] [--project] [--print] [--key <k>] [--scopes a,b] [--name <server>] \u2014 connect your editor's agent to this backend's MCP server
19702
20855
  dev branch [<name>] [--ttl <h=24>] [--no-env] [--print] | --list | --rm <name> [--yes]
19703
- config get \xB7 users \xB7 send \xB7 deliveries \xB7 api <METHOD> <path> ['<json>' | --data '<json>']
20856
+ config get \xB7 users add|list|import \xB7 send \xB7 deliveries \xB7 api <METHOD> <path> ['<json>' | --data '<json>']
19704
20857
  global: --json (machine-readable + exit codes)`);
19705
20858
  process.exit(cmd ? 1 : 0);
19706
20859
  }
@@ -19891,25 +21044,9 @@ async function devTeardown(dashBase, opts) {
19891
21044
  const dash = makeDash(dashBase);
19892
21045
  const cookie = await resolveDashCookie(dash, dashBase);
19893
21046
  const { alreadyGone } = await devDown(dash, cookie, { tenant_id: slot.tenant_id, slug: slot.slug });
19894
- const creds = loadCredentials();
19895
- if (creds.keys?.[slot.slug]) {
19896
- delete creds.keys[slot.slug];
19897
- saveCredentials(creds);
19898
- }
19899
- const cur = loadProject();
19900
- if (cur) {
19901
- let changed = false;
19902
- if (opts.branch !== void 0 && cur.branches?.[opts.branch]) {
19903
- delete cur.branches[opts.branch];
19904
- if (!Object.keys(cur.branches).length) delete cur.branches;
19905
- changed = true;
19906
- }
19907
- if (cur.dev && cur.dev.tenant_id === slot.tenant_id) {
19908
- delete cur.dev;
19909
- changed = true;
19910
- }
19911
- if (changed) saveProject(cur);
19912
- }
21047
+ const cleanup = cleanupAfterDelete(loadCredentials(), loadProject(), { tenant_id: slot.tenant_id, slug: slot.slug });
21048
+ if (cleanup.credsChanged) saveCredentials(cleanup.creds);
21049
+ if (cleanup.projChanged && cleanup.proj) saveProject(cleanup.proj);
19913
21050
  const what = opts.branch !== void 0 ? `dev branch '${opts.branch}' (tenant '${slot.slug}')` : `dev tenant '${slot.slug}'`;
19914
21051
  console.log(alreadyGone ? `\u2713 ${what} was already gone \u2014 cleared the local binding` : `\u2713 ${what} torn down`);
19915
21052
  }